dev_docs/tutorials/testing_plugins.mdx
This document outlines best practices and patterns for testing Kibana Plugins.
In general, we recommend three tiers of tests:
These tiers should roughly follow the traditional "testing pyramid", where there are more exhaustive testing at the unit level, fewer at the integration level, and very few at the functional level.
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.
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 */)
});
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.
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.
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 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:
Cons:
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:
// src/platform/plugins/shared/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
});
});
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 for the most important functionality.
Main subjects for tests should be:
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.
FTR development workflow:
# Start servers once, keep them running
yarn test:ftr:server --config path/to/config.ts
# In another terminal, run tests against the running servers
yarn test:ftr:runner --config path/to/config.ts
Useful FTR flags:
# Run a specific test by name
yarn test:ftr:runner --config path/to/config.ts --grep "test name"
# Debug with browser open, stop on first failure
yarn test:ftr --config path/to/config.ts --debug --bail
# Run against serverless ES
yarn test:ftr --config path/to/config.ts --esFrom serverless
Pros:
Cons:
You can reuse existing api_integration setup by registering a test file within a test loader. More about the existing FTR setup in the contribution guide
The tests cover:
// src/platform/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');
});
});
// src/platform/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]');
});
});
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');
});
});
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:
Cons:
To have access to Kibana TestUtils, you should create integration_tests folder and import test_utils within a test file:
// src/platform/plugins/my_plugin/server/integration_tests/formatter.test.ts
import { createRoot, request } from '@kbn/core-test-helpers-kbn-server';
describe('myPlugin', () => {
describe('GET /myPlugin/formatter', () => {
let root: ReturnType<typeof createRoot>;
beforeAll(async () => {
root = createRoot();
await root.preboot();
await root.setup();
await root.start();
}, 30000);
afterAll(async () => await root.shutdown());
it('validates given text', async () => {
const response = await 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 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 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.
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 createRoot>;
beforeAll(async () => {
root = 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 request
.get(root, '/myPlugin/formatter')
.query({ text: 'any text' })
.expect(400, 'bad bad request');
});
});
});
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:
uiSettings.get$())core.chrome.setIsVisible).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.
By following the renderApp 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.
/** 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>/** public/plugin.test.ts */
jest.mock('./application', () => ({ renderApp: jest.fn() }));
import { coreMock } from '@kbn/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.createAppMountParameters('/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);
});
});
The more interesting logic is in renderApp:
/** public/application.ts */
import React from 'react';
import ReactDOM from 'react-dom';
import { switchMap } from 'rxjs';
import { AppMountParameters, CoreStart } from '@kbn/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').pipe(switchMap(async (mySetting1) => {
const value = core.http.fetch(/** use `mySetting1` in request **/);
// ...
})).subscribe();
// 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:
/** public/application.test.ts */
import { createMemoryHistory } from 'history';
import { ScopedHistory } from '@kbn/core/public';
import { coreMock } from '@kbn/core/public/mocks';
import { renderApp } from './application';
describe('renderApp', () => {
it('mounts and unmounts UI', () => {
const params = coreMock.createAppMountParameters('/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.createAppMountParameters('/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.createAppMountParameters('/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);
})
});
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:
// src/platform/plugins/myplugin/server/lib/short_url_lookup.ts
import crypto from 'node:crypto';
import { SavedObjectsClientContract } from '@kbn/core/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;
}
});
},
};
// src/platform/plugins/myplugin/server/lib/short_url_lookup.test.ts
import { shortUrlLookup } from './short_url_lookup';
import { savedObjectsClientMock } from '@kbn/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.
// src/platform/plugins/myplugin/public/saved_query_service.ts
import {
SavedObjectsClientContract,
SimpleSavedObject,
} from '@kbn/core/public';
export type SavedQueryAttributes = {
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,
};
};
// src/platform/plugins/myplugin/public/saved_query_service.test.ts
import { createSavedQueryService, SavedQueryAttributes } from './saved_query_service';
import { savedObjectsServiceMock } from '@kbn/core/public/mocks';
import { SavedObjectsClientContract, SimpleSavedObject } from '@kbn/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();
}
});
});
});
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.
node scripts/es_archiver save <path> [index patterns...]esArchiver.load('path from root of repo');todo: fully worked out example
Also see <DocLink id="kibDevTutorialSavedObject" section="model-versions" text="Defining model versions"/>.
Model versions definitions are more structured than the legacy migration functions, which makes them harder
to test without the proper tooling. This is why a set of testing tools and utilities are exposed
from the @kbn/core-test-helpers-model-versions package, to help properly test the logic associated
with model version and their associated transformations.
For unit tests, the package exposes utilities to easily test the impact of transforming documents from a model version to another one, either upward or backward.
The createModelVersionTestMigrator helper allows to create a test migrator that can be used to
test model version changes between versions, by transforming documents the same way the migration
algorithm would during an upgrade.
Example:
import {
createModelVersionTestMigrator,
type ModelVersionTestMigrator
} from '@kbn/core-test-helpers-model-versions';
const mySoTypeDefinition = someSoType();
describe('mySoTypeDefinition model version transformations', () => {
let migrator: ModelVersionTestMigrator;
beforeEach(() => {
migrator = createModelVersionTestMigrator({ type: mySoTypeDefinition });
});
describe('Model version 2', () => {
it('properly backfill the expected fields when converting from v1 to v2', () => {
const obj = createSomeSavedObject();
const migrated = migrator.migrate({
document: obj,
fromVersion: 1,
toVersion: 2,
});
expect(migrated.properties).toEqual(expectedV2Properties);
});
it('properly removes the expected fields when converting from v2 to v1', () => {
const obj = createSomeSavedObject();
const migrated = migrator.migrate({
document: obj,
fromVersion: 2,
toVersion: 1,
});
expect(migrated.properties).toEqual(expectedV1Properties);
});
});
});
During integration tests, we can boot a real Elasticsearch cluster, allowing us to manipulate SO documents in a way almost similar to how it would be done on production runtime. With integration tests, we can even simulate the cohabitation of two Kibana instances with different model versions to assert the behavior of their interactions.
The package exposes a createModelVersionTestBed function that can be used to fully setup a
test bed for model version integration testing. It can be used to start and stop the ES server,
and to initiate the migration between the two versions we're testing.
Example:
import {
createModelVersionTestBed,
type ModelVersionTestKit
} from '@kbn/core-test-helpers-model-versions';
describe('myIntegrationTest', () => {
const testbed = createModelVersionTestBed();
let testkit: ModelVersionTestKit;
beforeAll(async () => {
await testbed.startES();
});
afterAll(async () => {
await testbed.stopES();
});
beforeEach(async () => {
// prepare the test, preparing the index and performing the SO migration
testkit = await testbed.prepareTestKit({
savedObjectDefinitions: [{
definition: mySoTypeDefinition,
// the model version that will be used for the "before" version
modelVersionBefore: 1,
// the model version that will be used for the "after" version
modelVersionAfter: 2,
}]
})
});
afterEach(async () => {
if(testkit) {
// delete the indices between each tests to perform a migration again
await testkit.tearsDown();
}
});
it('can be used to test model version cohabitation', async () => {
// last registered version is `1` (modelVersionBefore)
const repositoryV1 = testkit.repositoryBefore;
// last registered version is `2` (modelVersionAfter)
const repositoryV2 = testkit.repositoryAfter;
// do something with the two repositories, e.g
await repositoryV1.create(someAttrs, { id });
const v2docReadFromV1 = await repositoryV2.get('my-type', id);
expect(v2docReadFromV1.attributes).toEqual(whatIExpect);
});
});
Limitations:
Because the test bed is only creating the parts of Core required to instantiate the two SO repositories, and because we're not able to properly load all plugins (for proper isolation), the integration test bed currently has some limitations:
The serverless environment, and the fact that upgrade in such environments are performed in a way where, at some point, the old and new version of the application are living in cohabitation, leads to some particularities regarding the way the SO APIs works, and to some limitations / edge case that we need to document
fields option of the find savedObjects APIBy default, the find API (as any other SO API returning documents) will migrate all documents before
returning them, to ensure that documents can be used by both versions during a cohabitation (e.g an old
node searching for documents already migrated, or a new node searching for documents not yet migrated).
However, when using the fields option of the find API, the documents can't be migrated, as some
model version changes can't be applied against a partial set of attributes. For this reason, when the
fields option is provided, the documents returned from find will not be migrated.
Which is why, when using this option, the API consumer needs to make sure that all the fields passed
to the fields option were already present in the prior model version. Otherwise, it may lead to inconsistencies
during upgrades, where newly introduced or backfilled fields may not necessarily appear in the documents returned
from the search API when the option is used.
(note: both the previous and next version of Kibana must follow this rule then)
bulkUpdate for fields with large json blobsThe savedObjects bulkUpdate API will update documents client-side and then reindex the updated documents.
These update operations are done in-memory, and cause memory constraint issues when
updating many objects with large json blobs stored in some fields. As such, we recommend against using
bulkUpdate for savedObjects that:
json blobs in some fieldsHow to test ES clients
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).
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:
// src/platform/plugins/myplugin/public/plugin.ts
import { METRIC_TYPE } from '@kbn/analytics';
import { CoreSetup, CoreStart, Plugin } from '@kbn/core/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:
// src/platform/plugins/myplugin/public/suggestions/suggestion_service.ts
import { DataPublicPluginSetup, DataPublicPluginStart } from '@kbn/data-plugin/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');
},
};
}
}
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.
// src/platformplugins/myplugin/public/suggestions/suggestion_service.test.ts
import {
dataPluginMock,
Setup as DataPluginSetupMock,
Start as DataPluginStartMock,
} from '@kbn/data-plugin/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',
})
);
});
});
});
});
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.
// src/platform/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',
},
]);
});
});
});
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:
// src/platform/plugins/myplugin/public/plugin.test.ts
import { coreMock } from '@kbn/core/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/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:
// src/platform/plugins/myplugin/public/plugin.test.ts
import { coreMock } from '@kbn/core/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { usageCollectionPluginMock } from '@kbn/usage-collection-plugin/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');
});
});
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 for more information about marble testing.
The following examples all assume that the following snippet is included in every test file:
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.
Here is a very basic example of an interval-based API:
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:
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:
asynctake + 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:
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:
expectObservable ('301ms !') is used to perform manual unsubscription to the observable, as
interval never ends.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.Let's 'improve' our getUpdate$ API by allowing the consumer to manually terminate the observable chain using
a new abort$ option:
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:
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:
| symbol represents the completion of the observable.hot testing utility to create the abort$ observable to ensure correct emission timing.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:
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:
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:
- symbol represents one virtual time frame.# symbol represents an error.Error classes, the assertion can be against an error instance, but this doesn't work
with base errors.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.
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.
// 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('-|');
});
});