2021-08-09 14:03:35 +02:00
---
id: kibDevTutorialTestingPlugins
slug: /kibana-dev-docs/tutorial/testing-plugins
title: Testing Kibana Plugins
summary: Learn how to test different aspects of Kibana plugins
date: 2021-07-05
tags: ['kibana', 'onboarding', 'dev', 'architecture', 'testing']
---
This document outlines best practices and patterns for testing Kibana Plugins.
## Strategy
In general, we recommend three tiers of tests:
- Unit tests: small, fast, exhaustive, make heavy use of mocks for external dependencies
- Integration tests: higher-level tests that verify interactions between systems (eg. HTTP APIs, Elasticsearch API calls, calling other plugin contracts).
- End-to-end tests (e2e): tests that verify user-facing behavior through the browser
These tiers should roughly follow the traditional ["testing pyramid"](https://martinfowler.com/articles/practical-test-pyramid.html), where there are more exhaustive testing at the unit level, fewer at the integration level, and very few at the functional level.
## Core Integrations
### Core Mocks
When testing a plugin's integration points with Core APIs, it is heavily recommended to utilize the mocks provided in `src/core/server/mocks` and `src/core/public/mocks`. The majority of these mocks are dumb `jest` mocks that mimic the interface of their respective Core APIs, however they do not return realistic return values.
If the unit under test expects a particular response from a Core API, the test will need to set this return value explicitly. The return values are type checked to match the Core API where possible to ensure that mocks are updated when Core APIs changed.
#### Example
```typescript
import { elasticsearchServiceMock } from 'src/core/server/mocks';
test('my test', async () => {
// Setup mock and faked response
const esClient = elasticsearchServiceMock.createScopedClusterClient();
esClient.callAsCurrentUser.mockResolvedValue(/** insert ES response here */);
// Call unit under test with mocked client
const result = await myFunction(esClient);
// Assert that client was called with expected arguments
expect(esClient.callAsCurrentUser).toHaveBeenCalledWith(/** expected args */);
// Expect that unit under test returns expected value based on client's response
expect(result).toEqual(/** expected return value */)
});
```
## Strategies for specific Core APIs
### HTTP Routes
The HTTP API interface is another public contract of Kibana, although not every Kibana endpoint is for external use. When evaluating the required level of test coverage for an HTTP resource, make your judgment based on whether an endpoint is considered to be public or private. Public API is expected to have a higher level of test coverage.
Public API tests should cover the **observable behavior** of the system, therefore they should be close to the real user interactions as much as possible, ideally by using HTTP requests to communicate with the Kibana server as a real user would do.
##### Preconditions
We are going to add tests for `myPlugin` plugin that allows to format user-provided text, store and retrieve it later.
The plugin has *thin* route controllers isolating all the network layer dependencies and delegating all the logic to the plugin model.
```typescript
class TextFormatter {
public static async format(text: string, sanitizer: Deps['sanitizer']) {
// sanitizer.sanitize throws MisformedTextError when passed text contains HTML markup
const sanitizedText = await sanitizer.sanitize(text);
return sanitizedText;
}
public static async save(text: string, savedObjectsClient: SavedObjectsClient) {
const { id } = await savedObjectsClient.update('myPlugin-type', 'myPlugin', {
userText: text
});
return { id };
}
public static async getById(id: string, savedObjectsClient: SavedObjectsClient) {
const { attributes } = await savedObjectsClient.get('myPlugin-type', id);
return { text: attributes.userText };
}
}
router.get(
{
path: '/myPlugin/formatter',
validate: {
query: schema.object({
text: schema.string({ maxLength: 100 }),
}),
},
},
async (context, request, response) => {
try {
const formattedText = await TextFormatter.format(request.query.text, deps.sanitizer);
return response.ok({ body: formattedText });
} catch(error) {
if (error instanceof MisformedTextError) {
return response.badRequest({ body: error.message })
}
throw e;
}
}
);
router.post(
{
path: '/myPlugin/formatter/text',
validate: {
body: schema.object({
text: schema.string({ maxLength: 100 }),
}),
},
},
async (context, request, response) => {
try {
const { id } = await TextFormatter.save(request.query.text, context.core.savedObjects.client);
return response.ok({ body: { id } });
} catch(error) {
if (SavedObjectsErrorHelpers.isConflictError(error)) {
return response.conflict({ body: error.message })
}
throw e;
}
}
);
router.get(
{
path: '/myPlugin/formatter/text/{id}',
validate: {
params: schema.object({
id: schema.string(),
}),
},
},
async (context, request, response) => {
try {
const { text } = await TextFormatter.getById(request.params.id, context.core.savedObjects.client);
return response.ok({
body: text
});
} catch(error) {
if (SavedObjectsErrorHelpers.isNotFoundError(error)) {
return response.notFound()
}
throw e;
}
}
);
```
#### Unit testing
Unit tests provide the simplest and fastest way to test the logic in your route controllers and plugin models.
Use them whenever adding an integration test is hard and slow due to complex setup or the number of logic permutations.
Since all external core and plugin dependencies are mocked, you don't have the guarantee that the whole system works as
expected.
Pros:
- fast
- easier to debug
Cons:
- doesn't test against real dependencies
- doesn't cover integration with other plugins
###### Example
You can leverage existing unit-test infrastructure for this. You should add `*.test.ts` file and use dependencies mocks to cover the functionality with a broader test suit that covers:
- input permutations
- input edge cases
- expected exception
- interaction with dependencies
```typescript
// src/plugins/my_plugin/server/formatter.test.ts
describe('TextFormatter', () => {
describe('format()', () => {
const sanitizer = sanitizerMock.createSetup();
sanitizer.sanitize.mockImplementation((input: string) => `sanitizer result:${input}`);
it('formats text to a ... format', async () => {
expect(await TextFormatter.format('aaa', sanitizer)).toBe('...');
});
it('calls Sanitizer.sanitize with correct arguments', async () => {
await TextFormatter.format('aaa', sanitizer);
expect(sanitizer.sanitize).toHaveBeenCalledTimes(1);
expect(sanitizer.sanitize).toHaveBeenCalledWith('aaa');
});
it('throws MisformedTextError if passed string contains banned symbols', async () => {
sanitizer.sanitize.mockRejectedValueOnce(new MisformedTextError());
await expect(TextFormatter.format('any', sanitizer)).rejects.toThrow(MisformedTextError);
});
// ... other tests
});
});
```
#### Integration tests
Depending on the number of external dependencies, you can consider implementing several high-level integration tests.
They would work as a set of [smoke tests](https://en.wikipedia.org/wiki/Smoke_testing_(software)) for the most important functionality.
Main subjects for tests should be:
- authenticated / unauthenticated access to an endpoint.
- endpoint validation (params, query, body).
- main business logic.
- dependencies on other plugins.
##### Functional Test Runner
If your plugin relies on the elasticsearch server to store data and supports additional configuration, you can leverage the Functional Test Runner(FTR) to implement integration tests.
FTR bootstraps an elasticsearch and a Kibana instance and runs the test suite against it.
Pros:
- runs the whole Elastic stack
- tests cross-plugin integration
- emulates a real user interaction with the stack
- allows adjusting config values
Cons:
- slow start
- hard to debug
- brittle tests
###### Example
You can reuse existing [api_integration](https://github.com/elastic/kibana/blob/master/test/api_integration/config.js) setup by registering a test file within a
[test loader](https://github.com/elastic/kibana/blob/master/test/api_integration/apis/index.ts). More about the existing FTR setup in the
[contribution guide](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#running-specific-kibana-tests)
The tests cover:
- authenticated / non-authenticated user access (when applicable)
```typescript
// test/api_integration/apis/my_plugin/something.ts
export default function({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const security = getService('security');
describe('myPlugin', () => {
it('returns limited info when not authenticated', async () => {
await security.logout();
const response = await supertest
.get('/myPlugin/health')
.set('content-type', 'application/json')
.expect(200);
expect(response.body).to.have.property('basicInfo');
expect(response.body).not.to.have.property('detailedInfo');
});
it('returns detailed info when authenticated', async () => {
await security.loginAsSuperUser();
const response = await supertest
.get('/myPlugin/health')
.set('content-type', 'application/json')
.expect(200);
expect(response.body).to.have.property('basicInfo');
expect(response.body).to.have.property('detailedInfo');
});
});
```
- request validation
```typescript
// test/api_integration/apis/my_plugin/something.ts
export default function({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('myPlugin', () => {
it('validate params before to store text', async () => {
const response = await supertest
.post('/myPlugin/formatter/text')
.set('content-type', 'application/json')
.send({ text: 'aaa'.repeat(100) })
.expect(400);
expect(response.body).to.have.property('message');
expect(response.body.message).to.contain('must have a maximum length of [100]');
});
});
```
- the main logic of the plugin
```typescript
export default function({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('myPlugin', () => {
it('stores text', async () => {
const response = await supertest
.post('/myPlugin/formatter/text')
.set('content-type', 'application/json')
.send({ text: 'aaa' })
.expect(200);
expect(response.body).to.have.property('id');
expect(response.body.id).to.be.a('string');
});
it('retrieves text', async () => {
const { body } = await supertest
.post('/myPlugin/formatter/text')
.set('content-type', 'application/json')
.send({ text: 'bbb' })
.expect(200);
const response = await supertest.get(`/myPlugin/formatter/text/${body.id}`).expect(200);
expect(response.text).be('bbb');
});
it('returns NotFound error when cannot find a text', async () => {
await supertest
.get('/myPlugin/something/missing')
.expect(404, 'Saved object [myPlugin-type/missing] not found');
});
});
```
##### TestUtils
It can be utilized if your plugin doesn't interact with the elasticsearch server or mocks the own methods doing so.
Runs tests against real Kibana server instance.
Pros:
- runs the real Kibana instance
- tests cross-plugin integration
- emulates a real user interaction with the HTTP resources
Cons:
- faster than FTR because it doesn't run elasticsearch instance, but still slow
- hard to debug
- doesn't cover Kibana CLI logic
###### Example
To have access to Kibana TestUtils, you should create `integration_tests` folder and import `test_utils` within a test file:
```typescript
// src/plugins/my_plugin/server/integration_tests/formatter.test.ts
import * as kbnTestServer from 'src/core/test_helpers/kbn_server';
describe('myPlugin', () => {
describe('GET /myPlugin/formatter', () => {
let root: ReturnType<typeof kbnTestServer.createRoot>;
beforeAll(async () => {
root = kbnTestServer.createRoot();
await root.preboot();
await root.setup();
await root.start();
}, 30000);
afterAll(async () => await root.shutdown());
it('validates given text', async () => {
const response = await kbnTestServer.request
.get(root, '/myPlugin/formatter')
.query({ text: 'input string'.repeat(100) })
.expect(400);
expect(response.body).toHaveProperty('message');
});
it('formats given text', async () => {
const response = await kbnTestServer.request
.get(root, '/myPlugin/formatter')
.query({ text: 'input string' })
.expect(200);
expect(response.text).toBe('...');
});
it('returns BadRequest if passed string contains banned symbols', async () => {
await kbnTestServer.request
.get(root, '/myPlugin/formatter')
.query({ text: '<script>' })
.expect(400, 'Text cannot contain unescaped HTML markup.');
});
});
});
```
Sometimes we want to test a route controller logic and don't rely on the internal logic of the platform or a third-party plugin.
Then we can apply a hybrid approach and mock the necessary method of `TextFormatter` model to test how `MisformedTextError`
handled in the route handler without calling `sanitizer` dependency directly.
```typescript
jest.mock('../path/to/model');
import { TextFormatter } from '../path/to/model';
import { MisformedTextError } from '../path/to/sanitizer'
describe('myPlugin', () => {
describe('GET /myPlugin/formatter', () => {
let root: ReturnType<typeof kbnTestServer.createRoot>;
beforeAll(async () => {
root = kbnTestServer.createRoot();
await root.preboot();
await root.setup();
await root.start();
}, 30000);
afterAll(async () => await root.shutdown());
it('returns BadRequest if Sanitizer throws MisformedTextError', async () => {
TextFormatter.format.mockRejectedValueOnce(new MisformedTextError());
await kbnTestServer.request
.get(root, '/myPlugin/formatter')
.query({ text: 'any text' })
.expect(400, 'bad bad request');
});
});
});
```
### Applications
Kibana Platform applications have less control over the page than legacy applications did. It is important that your app is built to handle it's co-habitance with other plugins in the browser. Applications are mounted and unmounted from the DOM as the user navigates between them, without full-page refreshes, as a single-page application (SPA).
These long-lived sessions make cleanup more important than before. It's entirely possible a user has a single browsing session open for weeks at a time, without ever doing a full-page refresh. Common things that need to be cleaned up (and tested!) when your application is unmounted:
- Subscriptions and polling (eg. `uiSettings.get$()`)
- Any Core API calls that set state (eg. `core.chrome.setIsVisible`).
- Open connections (eg. a Websocket)
While applications do get an opportunity to unmount and run cleanup logic, it is also important that you do not _depend_ on this logic to run. The browser tab may get closed without running cleanup logic, so it is not guaranteed to be run. For instance, you should not depend on unmounting logic to run in order to save state to `localStorage` or to the backend.
#### Example
By following the [renderApp](https://github.com/elastic/kibana/blob/master/src/core/CONVENTIONS.md#applications) convention, you can greatly reduce the amount of logic in your application's mount function. This makes testing your application's actual rendering logic easier.
```typescript jsx
/** public/plugin.ts */
class Plugin {
setup(core) {
core.application.register({
// id, title, etc.
async mount(params) {
const [{ renderApp }, [coreStart, startDeps]] = await Promise.all([
import('./application'),
core.getStartServices()
]);
return renderApp(params, coreStart, startDeps);
}
})
}
}
```
We _could_ still write tests for this logic, but you may find that you're just asserting the same things that would be covered by type-checks.
<details>
<summary>See example</summary>
```typescript
/** public/plugin.test.ts */
jest.mock('./application', () => ({ renderApp: jest.fn() }));
import { coreMock } from 'src/core/public/mocks';
import { renderApp: renderAppMock } from './application';
import { Plugin } from './plugin';
describe('Plugin', () => {
it('registers an app', () => {
const coreSetup = coreMock.createSetup();
new Plugin(coreMock.createPluginInitializerContext()).setup(coreSetup);
expect(coreSetup.application.register).toHaveBeenCalledWith({
id: 'myApp',
mount: expect.any(Function)
});
});
// Test the glue code from Plugin -> renderApp
it('application.mount wires up dependencies to renderApp', async () => {
const coreSetup = coreMock.createSetup();
const [coreStartMock, startDepsMock] = await coreSetup.getStartServices();
const unmountMock = jest.fn();
renderAppMock.mockReturnValue(unmountMock);
const params = coreMock.createAppMountParamters('/fake/base/path');
new Plugin(coreMock.createPluginInitializerContext()).setup(coreSetup);
// Grab registered mount function
const mount = coreSetup.application.register.mock.calls[0][0].mount;
const unmount = await mount(params);
expect(renderAppMock).toHaveBeenCalledWith(params, coreStartMock, startDepsMock);
expect(unmount).toBe(unmountMock);
});
});
```
</details>
The more interesting logic is in `renderApp`:
```typescript
/** public/application.ts */
import React from 'react';
import ReactDOM from 'react-dom';
import { AppMountParameters, CoreStart } from 'src/core/public';
import { AppRoot } from './components/app_root';
export const renderApp = (
{ element, history }: AppMountParameters,
core: CoreStart,
plugins: MyPluginDepsStart
) => {
// Hide the chrome while this app is mounted for a full screen experience
core.chrome.setIsVisible(false);
// uiSettings subscription
const uiSettingsClient = core.uiSettings.client;
const pollingSubscription = uiSettingClient.get$('mysetting1').subscribe(async mySetting1 => {
const value = core.http.fetch(/** use `mySetting1` in request **/);
// ...
});
// Render app
ReactDOM.render(
<AppRoot routerHistory={history} core={core} plugins={plugins} />,
element
);
return () => {
// Unmount UI
ReactDOM.unmountComponentAtNode(element);
// Close any subscriptions
pollingSubscription.unsubscribe();
// Make chrome visible again
core.chrome.setIsVisible(true);
};
};
```
In testing `renderApp` you should be verifying that:
1) Your application mounts and unmounts correctly
2) Cleanup logic is completed as expected
```typescript
/** public/application.test.ts */
import { createMemoryHistory } from 'history';
import { ScopedHistory } from 'src/core/public';
import { coreMock } from 'src/core/public/mocks';
import { renderApp } from './application';
describe('renderApp', () => {
it('mounts and unmounts UI', () => {
const params = coreMock.createAppMountParamters('/fake/base/path');
const core = coreMock.createStart();
// Verify some expected DOM element is rendered into the element
const unmount = renderApp(params, core, {});
expect(params.element.querySelector('.some-app-class')).not.toBeUndefined();
// Verify the element is empty after unmounting
unmount();
expect(params.element.innerHTML).toEqual('');
});
it('unsubscribes from uiSettings', () => {
const params = coreMock.createAppMountParamters('/fake/base/path');
const core = coreMock.createStart();
// Create a fake Subject you can use to monitor observers
const settings$ = new Subject();
core.uiSettings.get$.mockReturnValue(settings$);
// Verify mounting adds an observer
const unmount = renderApp(params, core, {});
expect(settings$.observers.length).toBe(1);
// Verify no observers remaining after unmount is called
unmount();
expect(settings$.observers.length).toBe(0);
});
it('resets chrome visibility', () => {
const params = coreMock.createAppMountParamters('/fake/base/path');
const core = coreMock.createStart();
// Verify stateful Core API was called on mount
const unmount = renderApp(params, core, {});
expect(core.chrome.setIsVisible).toHaveBeenCalledWith(false);
core.chrome.setIsVisible.mockClear(); // reset mock
// Verify stateful Core API was called on unmount
unmount();
expect(core.chrome.setIsVisible).toHaveBeenCalledWith(true);
})
});
```
2021-08-10 16:52:10 +02:00
### SavedObjectsClient
2021-08-09 14:03:35 +02:00
#### Unit Tests
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 <path> [index patterns...]`
3. Load the fixtures in your test using esArchiver `esArchiver.load('path from root of repo')`;
_todo: fully worked out example_
2021-08-10 16:52:10 +02:00
### Saved Objects migrations
_Also see <DocLink id="kibDevTutorialSavedObject" section="migrations" text="How to write a migration"/>._
It is critical that you have extensive tests to ensure that migrations behave as expected with all possible input
documents. Given how simple it is to test all the branch conditions in a migration function and the high impact of a
bug in this code, there’ s really no reason not to aim for 100% test code coverage.
It's recommend that you primarily leverage unit testing with Jest for testing your migration function. Unit tests will
be a much more effective approach to testing all the different shapes of input data and edge cases that your migration
may need to handle. With more complex migrations that interact with several components or may behave different depending
on registry contents (such as Embeddable migrations), we recommend that you use the Jest Integration suite which allows
you to create a full instance Kibana and all plugins in memory and leverage the import API to test migrating documents.
#### Throwing exceptions
Keep in mind that any exception thrown by your migration function will cause Kibana to fail to upgrade. This should almost
never happen for our end users and we should be exhaustive in our testing to be sure to catch as many edge cases that we
could possibly handle. This entails ensuring that the migration is written defensively; we should try to avoid every bug
possible in our implementation.
In general, exceptions should only be thrown when the input data is corrupted and doesn't match the expected schema. In
such cases, it's important that an informative error message is included in the exception and we do not rely on implicit
runtime exceptions such as "null pointer exceptions" like `TypeError: Cannot read property 'foo' of undefined`.
#### Unit testing
Unit testing migration functions is typically pretty straight forward and comparable to other types of Jest testing. In
general, you should focus this tier of testing on validating output and testing input edge cases. One focus of this tier
should be trying to find edge cases that throw exceptions the migration shouldn't. As you can see in this simple
example, the coverage here is very exhaustive and verbose, which is intentional.
```ts
import { migrateCaseFromV7_9_0ToV7_10_0 } from './case_migrations';
const validInput_7_9_0 = {
id: '1',
type: 'case',
attributes: {
connector_id: '1234';
}
}
describe('Case migrations v7.7.0 -> v7.8.0', () => {
it('transforms the connector field', () => {
expect(migrateCaseFromV7_9_0ToV7_10_0(validInput_7_9_0)).toEqual({
id: '1',
type: 'case',
attributes: {
connector: {
id: '1234', // verify id was moved into subobject
name: 'none', // verify new default field was added
}
}
});
});
it('handles empty string', () => {
expect(migrateCaseFromV7_9_0ToV7_10_0({
id: '1',
type: 'case',
attributes: {
connector_id: ''
}
})).toEqual({
id: '1',
type: 'case',
attributes: {
connector: {
id: 'none',
name: 'none',
}
}
});
});
it('handles null', () => {
expect(migrateCaseFromV7_9_0ToV7_10_0({
id: '1',
type: 'case',
attributes: {
connector_id: null
}
})).toEqual({
id: '1',
type: 'case',
attributes: {
connector: {
id: 'none',
name: 'none',
}
}
});
});
it('handles undefined', () => {
expect(migrateCaseFromV7_9_0ToV7_10_0({
id: '1',
type: 'case',
attributes: {
// Even though undefined isn't a valid JSON or Elasticsearch value, we should test it anyways since there
// could be some JavaScript layer that casts the field to `undefined` for some reason.
connector_id: undefined
}
})).toEqual({
id: '1',
type: 'case',
attributes: {
connector: {
id: 'none',
name: 'none',
}
}
});
expect(migrateCaseFromV7_9_0ToV7_10_0({
id: '1',
type: 'case',
attributes: {
// also test without the field present at all
}
})).toEqual({
id: '1',
type: 'case',
attributes: {
connector: {
id: 'none',
name: 'none',
}
}
});
});
});
```
2021-09-09 08:14:56 +02:00
You can generate code coverage report for a single plugin.
```bash
yarn jest --coverage --config src/plugins/console/jest.config.js
```
Html report should be available in `target/kibana-coverage/jest/src/plugins/console` path
We run code coverage daily on CI and ["Kibana Stats cluster"](https://kibana-stats.elastic.dev/s/code-coverage/app/home)
can be used to view statistics. The report combines code coverage for all jest tests within Kibana repository.
2021-08-10 16:52:10 +02:00
#### Integration testing
With more complicated migrations, the behavior of the migration may be dependent on values from other plugins which may
be difficult or even impossible to test with unit tests. You need to actually bootstrap Kibana, load the plugins, and
then test the full end-to-end migration. This type of set up will also test ingesting your documents into Elasticsearch
against the mappings defined by your Saved Object type.
This can be achieved using the `jest_integration` suite and the `kbnTestServer` utility for starting an in-memory
instance of Kibana. You can then leverage the import API to test migrations. This API applies the same migrations to
imported documents as are applied at Kibana startup and is much easier to work with for testing.
```ts
// You may need to adjust these paths depending on where your test file is located.
// The absolute path is src/core/test_helpers/so_migrations
import { createTestHarness, SavedObjectTestHarness } from '../../../../src/core/test_helpers/so_migrations';
describe('my plugin migrations', () => {
let testHarness: SavedObjectTestHarness;
beforeAll(async () => {
testHarness = createTestHarness();
await testHarness.start();
});
afterAll(async () => {
await testHarness.stop();
});
it('successfully migrates valid case documents', async () => {
expect(
await testHarness.migrate([
{ type: 'case', id: '1', attributes: { connector_id: '1234' }, references: [] },
{ type: 'case', id: '2', attributes: { connector_id: '' }, references: [] },
{ type: 'case', id: '3', attributes: { connector_id: null }, references: [] },
])
).toEqual([
expect.objectContaining(
{ type: 'case', id: '1', attributes: { connector: { id: '1234', name: 'none' } } }),
expect.objectContaining(
{ type: 'case', id: '2', attributes: { connector: { id: 'none', name: 'none' } } }),
expect.objectContaining(
{ type: 'case', id: '3', attributes: { connector: { id: 'none', name: 'none' } } }),
])
})
})
```
There are some caveats about using the import/export API for testing migrations:
- You cannot test the startup behavior of Kibana this way. This should not have any effect on type migrations but does
mean that this method cannot be used for testing the migration algorithm itself.
- While not yet supported, if support is added for migrations that affect multiple types, it's possible that the
behavior during import may vary slightly from the upgrade behavior.
2021-08-09 14:03:35 +02:00
### Elasticsearch
_How to test ES clients_
## Plugin integrations
In the Kibana Platform, all plugin's dependencies to other plugins are explicitly declared in their `kibana.json`
manifest. As for `core`, the dependencies `setup` and `start` contracts are injected in your plugin's respective
`setup` and `start` phases. One of the upsides with testing is that every usage of the dependencies is explicit,
and that the plugin's contracts must be propagated to the parts of the code using them, meaning that isolating a
specific logical component for unit testing is way easier than in legacy.
The approach to test parts of a plugin's code that is relying on other plugins is quite similar to testing
code using `core` APIs: it's expected to mock the dependency, and make it return the value the test is expecting.
Most plugins are defining mocks for their contracts. The convention is to expose them in a `mocks` file in
`my_plugin/server` and/or `my_plugin/public`. For example for the `data` plugin, the client-side mocks are located in
`src/plugins/data/public/mocks.ts`. When such mocks are present, it's strongly recommended to use them
when testing against dependencies. Otherwise, one should create it's own mocked implementation of the dependency's
contract (and should probably ping the plugin's owner to ask them to add proper contract mocks).
### Preconditions
For these examples, we are going to see how we should test the `myPlugin` plugin.
This plugin declares the `data` plugin as a `required` dependency and the `usageCollection` plugin as an `optional`
one. It also exposes a `getSpecialSuggestions` API in it's start contract, which relies on the `data` plugin to retrieve
data.
`MyPlugin` plugin definition:
```typescript
// src/plugins/myplugin/public/plugin.ts
import { METRIC_TYPE } from '@kbn/analytics';
import { CoreSetup, CoreStart, Plugin } from 'kibana/public';
import { DataPublicPluginSetup, DataPublicPluginStart } from '../../data/public';
import { UsageCollectionSetup } from '../../usage_collection/public';
import { SuggestionsService } from './suggestions';
interface MyPluginSetupDeps {
data: DataPublicPluginSetup;
usageCollection?: UsageCollectionSetup;
}
interface MyPluginStartDeps {
data: DataPublicPluginStart;
}
export class MyPlugin implements Plugin<MyPluginSetup, MyPluginStart, MyPluginSetupDeps, MyPluginStartDeps> {
private suggestionsService = new SuggestionsService();
public setup(core: CoreSetup, { data, usageCollection }: MyPluginSetupDeps) {
// setup our internal service
this.suggestionsService.setup(data);
// an example on using an optional dependency that will be tested
if (usageCollection) {
usageCollection.reportUiCounter('my_plugin', METRIC_TYPE.LOADED, 'my_event');
}
// or in a shorter version
usageCollection?.reportUiCounter('my_plugin', METRIC_TYPE.LOADED, 'my_event');
return {};
}
public start(core: CoreStart, { data }: MyPluginStartDeps) {
const suggestions = this.suggestionsService.start(data);
return {
getSpecialSuggestions: (query: string) => suggestions.getSuggestions(query),
};
}
public stop() {}
}
export type MyPluginSetup = ReturnType<MyPlugin['setup']>;
export type MyPluginStart = ReturnType<MyPlugin['start']>;
```
The underlying `SuggestionsService` implementation:
```typescript
// src/plugins/myplugin/public/suggestions/suggestion_service.ts
import { DataPublicPluginSetup, DataPublicPluginStart } from '../../../data/public';
// stubs for testing purposes
const suggestDependingOn = (...args: any[]) => [];
const baseOptions = {} as any;
export const defaultSuggestions = [
{
text: 'a default suggestion',
},
] as any[];
export class SuggestionsService {
public setup(data: DataPublicPluginSetup) {
// register a suggestion provider to the `data` dependency plugin
data.autocomplete.addQuerySuggestionProvider('fr', async args => {
return suggestDependingOn(args);
});
}
public start(data: DataPublicPluginStart) {
return {
getSuggestions: async (query: string) => {
// use the `data` plugin contract to retrieve arbitrary data
// note: this logic does not really make any sense and is only here to introduce a behavior to test
const baseSuggestions = await data.autocomplete.getQuerySuggestions({
...baseOptions,
query,
});
if (!baseSuggestions || baseSuggestions.length === 0) {
return defaultSuggestions;
}
return baseSuggestions.filter(suggestion => suggestion.type !== 'conjunction');
},
};
}
}
```
### Testing dependencies usages
A plugin should test expected usage and calls on it's dependency plugins' API.
Some calls, such as 'registration' APIs exposed from dependency plugins, should be checked,
to ensure both that they are actually executed, and performed with the correct parameters.
For our example plugin's `SuggestionsService`, we should assert that the suggestion provider is correctly
registered to the `data` plugin during the `setup` phase, and that `getSuggestions` calls
`autocomplete.getQuerySuggestions` with the correct parameters.
```typescript
// src/plugins/myplugin/public/suggestions/suggestion_service.test.ts
import {
dataPluginMock,
Setup as DataPluginSetupMock,
Start as DataPluginStartMock,
} from '../../../data/public/mocks';
import { SuggestionsService } from './suggestion_service';
describe('SuggestionsService', () => {
let service: SuggestionsService;
let dataSetup: DataPluginSetupMock;
let dataStart: DataPluginStartMock;
beforeEach(() => {
service = new SuggestionsService();
dataSetup = dataPluginMock.createSetupContract();
dataStart = dataPluginMock.createStartContract();
});
describe('#setup', () => {
it('registers the query suggestion provider to the data plugin', () => {
service.setup(dataSetup);
expect(dataSetup.autocomplete.addQuerySuggestionProvider).toHaveBeenCalledTimes(1);
expect(dataSetup.autocomplete.addQuerySuggestionProvider).toHaveBeenCalledWith(
'fr',
expect.any(Function)
);
});
});
describe('#start', () => {
describe('#getSuggestions', () => {
it('calls getQuerySuggestions with the correct query', async () => {
service.setup(dataSetup);
const serviceStart = service.start(dataStart);
await serviceStart.getSuggestions('some query');
expect(dataStart.autocomplete.getQuerySuggestions).toHaveBeenCalledTimes(1);
expect(dataStart.autocomplete.getQuerySuggestions).toHaveBeenCalledWith(
expect.objectContaining({
query: 'some query',
})
);
});
});
});
});
```
### Testing components consuming the dependencies
When testing parts of your plugin code that depends on the dependency plugin's data, the best approach
is to mock the dependency to be able to get the behavior expected for the test.
In this example, we are going to mock the results of `autocomplete.getQuerySuggestions` to be able to test
the service's `getSuggestions` method.
```typescript
// src/plugins/myplugin/public/suggestions/suggestion_service.ts
describe('#start', () => {
describe('#getSuggestions', () => {
it('returns the default suggestions when autocomplete returns no results', async () => {
dataStart.autocomplete.getQuerySuggestions.mockResolvedValue([]);
service.setup(dataSetup);
const serviceStart = service.start(dataStart);
const results = await serviceStart.getSuggestions('some query');
expect(results).toEqual(defaultSuggestions);
});
it('excludes conjunctions from the autocomplete results', async () => {
dataStart.autocomplete.getQuerySuggestions.mockResolvedValue([
{
type: 'field',
text: 'field suggestion',
},
{
type: 'conjunction',
text: 'conjunction suggestion',
},
]);
service.setup(dataSetup);
const serviceStart = service.start(dataStart);
const results = await serviceStart.getSuggestions('some query');
expect(results).toEqual([
{
type: 'field',
text: 'field suggestion',
},
]);
});
});
});
```
### Testing optional plugin dependencies
Plugins should test that their behavior remains correct when their optional dependencies are either available or not.
A basic test would be to ensure that the plugin properly initialize without error when the optional
dependency is missing:
```typescript
// src/plugins/myplugin/public/plugin.test.ts
import { coreMock } from '../../../core/public/mocks';
import { dataPluginMock } from '../../data/public/mocks';
import { MyPlugin } from './plugin';
describe('Plugin', () => {
it('initializes correctly if usageCollection is disabled', () => {
const plugin = new MyPlugin(coreMock.createPluginInitializerContext());
const coreSetup = coreMock.createSetup();
const setupDeps = {
data: dataPluginMock.createSetupContract(),
// optional usageCollector dependency is not available
};
const coreStart = coreMock.createStart();
const startDeps = {
data: dataPluginMock.createStartContract(),
};
expect(() => {
plugin.setup(coreSetup, setupDeps);
}).not.toThrow();
expect(() => {
plugin.start(coreStart, startDeps);
}).not.toThrow();
});
});
```
Then we should test that when optional dependency is properly used when present:
```typescript
// src/plugins/myplugin/public/plugin.test.ts
import { coreMock } from '../../../core/public/mocks';
import { dataPluginMock } from '../../data/public/mocks';
import { usageCollectionPluginMock } from '../../usage_collection/public/mocks';
import { METRIC_TYPE } from '@kbn/analytics';
import { MyPlugin } from './plugin';
describe('Plugin', () => {
// [...]
it('enables trackUserAgent when usageCollection is available', async () => {
const plugin = new MyPlugin(coreMock.createPluginInitializerContext());
const coreSetup = coreMock.createSetup();
const usageCollectionSetup = usageCollectionPluginMock.createSetupContract();
const setupDeps = {
data: dataPluginMock.createSetupContract(),
usageCollection: usageCollectionSetup,
};
plugin.setup(coreSetup, setupDeps);
expect(usageCollectionSetup.reportUiCounter).toHaveBeenCalledTimes(2);
expect(usageCollectionSetup.reportUiCounter).toHaveBeenCalledWith('my_plugin', METRIC_TYPE.LOADED, 'my_event');
});
});
```
## RXJS testing
### Testing RXJS observables with marble
Testing observable based APIs can be challenging, specially when asynchronous operators or sources are used,
or when trying to assert against emission's timing.
Fortunately, RXJS comes with it's own `marble` testing module to greatly facilitate that kind of testing.
See [the official doc](https://rxjs-dev.firebaseapp.com/guide/testing/marble-testing) for more information about marble testing.
### Preconditions
The following examples all assume that the following snippet is included in every test file:
```typescript
import { TestScheduler } from 'rxjs/testing';
const getTestScheduler = () =>
new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
```
`getTestScheduler` creates a `TestScheduler` that is wired on `jest`'s `expect` statement when comparing an observable's time frame.
### Examples
#### Testing an interval based observable
Here is a very basic example of an interval-based API:
```typescript
class FooService {
setup() {
return {
getUpdate$: () => {
return interval(100).pipe(map((count) => `update-${count + 1}`));
},
};
}
}
```
If we were to be adding a test that asserts the correct behavior of this API without using marble testing, it
would probably be something like:
```typescript
it('getUpdate$ emits updates every 100ms', async () => {
const service = new FooService();
const { getUpdate$ } = service.setup();
expect(await getUpdate$().pipe(take(3), toArray()).toPromise()).toEqual([
'update-1',
'update-2',
'update-3',
]);
});
```
Note that if we are able to test the correct value of each emission, we don't have any way to assert that
the interval of 100ms was respected. Even using a subscription based test to try to do so would result
in potential flakiness, as the subscription execution could trigger on the `101ms` time frame for example.
It also may be important to note:
- as we need to convert the observable to a promise and wait for the result, the test is `async`
- we need to perform observable transformation (`take` + `toArray`) in the test to have an usable value to assert against.
Marble testing would allow to get rid of these limitations. An equivalent and improved marble test could be:
```typescript
describe('getUpdate$', () => {
it('emits updates every 100ms', () => {
getTestScheduler().run(({ expectObservable }) => {
const { getUpdate$ } = service.setup();
expectObservable(getUpdate$(), '301ms !').toBe('100ms a 99ms b 99ms c', {
a: 'update-1',
b: 'update-2',
c: 'update-3',
});
});
});
});
```
Notes:
- the test is now synchronous
- the second parameter of `expectObservable` (`'301ms !'`) is used to perform manual unsubscription to the observable, as
`interval` never ends.
- an emission is considered a time frame, meaning that after the initial `a` emission, we are at the frame `101`, not `100`
which is why we are then only using a `99ms` gap between a->b and b->c.
#### Testing observable completion
Let's 'improve' our `getUpdate$` API by allowing the consumer to manually terminate the observable chain using
a new `abort$` option:
```typescript
class FooService {
setup() {
return {
// note: using an abortion observable is usually an anti-pattern, as unsubscribing from the observable
// is, most of the time, a better solution. This is only used for the example purpose.
getUpdate$: ({ abort$ = EMPTY }: { abort$?: Observable<undefined> } = {}) => {
return interval(100).pipe(
takeUntil(abort$),
map((count) => `update-${count + 1}`)
);
},
};
}
}
```
We would then add a test to assert than this new option usage is respected:
```typescript
it('getUpdate$ completes when `abort$` emits', () => {
const service = new FooService();
getTestScheduler().run(({ expectObservable, hot }) => {
const { getUpdate$ } = service.setup();
const abort$ = hot('149ms a', { a: undefined });
expectObservable(getUpdate$({ abort$ })).toBe('100ms a 48ms |', {
a: 'update-1',
});
});
});
```
Notes:
- the `|` symbol represents the completion of the observable.
- we are here using the `hot` testing utility to create the `abort$` observable to ensure correct emission timing.
#### Testing observable errors
Testing errors thrown by the observable is very close to the previous examples and is done using
the third parameter of `expectObservable`.
Say we have a service in charge of processing data from an observable and returning the results in a new observable:
```typescript
interface SomeDataType {
id: string;
}
class BarService {
setup() {
return {
processDataStream: (data$: Observable<SomeDataType>) => {
return data$.pipe(
map((data) => {
if (data.id === 'invalid') {
throw new Error(`invalid data: '${data.id}'`);
}
return {
...data,
processed: 'additional-data',
};
})
);
},
};
}
}
```
We could write a test that asserts the service properly emit processed results until an invalid data is encountered:
```typescript
it('processDataStream throw an error when processing invalid data', () => {
getTestScheduler().run(({ expectObservable, hot }) => {
const service = new BarService();
const { processDataStream } = service.setup();
const data = hot('--a--b--(c|)', {
a: { id: 'a' },
b: { id: 'invalid' },
c: { id: 'c' },
});
expectObservable(processDataStream(data)).toBe(
'--a--#',
{
a: { id: 'a', processed: 'additional-data' },
},
`'[Error: invalid data: 'invalid']'`
);
});
});
```
Notes:
- the `-` symbol represents one virtual time frame.
- the `#` symbol represents an error.
- when throwing custom `Error` classes, the assertion can be against an error instance, but this doesn't work
with base errors.
#### Testing promise based observables
In some cases, the observable we want to test is based on a Promise (like `of(somePromise).pipe(...)`). This can occur
when using promise-based services, such as core's `http`, for instance.
```typescript
export const callServerAPI = (
http: HttpStart,
body: Record<string, any>,
{ abort$ }: { abort$: Observable<undefined> }
): Observable<SomeResponse> => {
let controller: AbortController | undefined;
if (abort$) {
controller = new AbortController();
abort$.subscribe(() => {
controller!.abort();
});
}
return from(
http.post<SomeResponse>('/api/endpoint', {
body,
signal: controller?.signal,
})
).pipe(
takeUntil(abort$ ?? EMPTY),
map((response) => response.results)
);
};
```
Testing that kind of promise based observable does not work out of the box with marble testing, as the asynchronous promise resolution
is not handled by the test scheduler's 'sandbox'.
Fortunately, there are workarounds for this problem. The most common one being to mock the promise-returning API to return
an observable instead for testing, as `of(observable)` also works and returns the input observable.
Note that when doing so, the test suite must also include tests using a real promise value to ensure correct behavior in real situation.
```typescript
// NOTE: test scheduler do not properly work with promises because of their asynchronous nature.
// we are cheating here by having `http.post` return an observable instead of a promise.
// this still allows more finely grained testing about timing, and asserting that the method
// works properly when `post` returns a real promise is handled in other tests of this suite
it('callServerAPI result observable emits when the response is received', () => {
const http = httpServiceMock.createStartContract();
getTestScheduler().run(({ expectObservable, hot }) => {
// need to cast the observable as `any` because http.post.mockReturnValue expects a promise, see previous comment
http.post.mockReturnValue(hot('---(a|)', { a: { someData: 'foo' } }) as any);
const results = callServerAPI(http, { query: 'term' }, {});
expectObservable(results).toBe('---(a|)', {
a: { someData: 'foo' },
});
});
});
it('completes without returning results if aborted$ emits before the response', () => {
const http = httpServiceMock.createStartContract();
getTestScheduler().run(({ expectObservable, hot }) => {
// need to cast the observable as `any` because http.post.mockReturnValue expects a promise, see previous comment
http.post.mockReturnValue(hot('---(a|)', { a: { someData: 'foo' } }) as any);
const aborted$ = hot('-(a|)', { a: undefined });
const results = callServerAPI(http, { query: 'term' }, { aborted$ });
expectObservable(results).toBe('-|');
});
});
```