# Testing OpenSearch Dashboards Plugins This document outlines best practices and patterns for testing OpenSearch Dashboards Plugins. - [Testing OpenSearch Dashboards Plugins](#testing-opensearch-dashboards-plugins) - [Strategy](#strategy) - [New concerns in the OpenSearch Dashboards Platform](#new-concerns-in-the-opensearch-dashboards-platform) - [Core Integrations](#core-integrations) - [Core Mocks](#core-mocks) - [Example](#example) - [Strategies for specific Core APIs](#strategies-for-specific-core-apis) - [HTTP Routes](#http-routes) - [Preconditions](#preconditions) - [Unit testing](#unit-testing) - [Example](#example-1) - [Integration tests](#integration-tests) - [Functional Test Runner](#functional-test-runner) - [Example](#example-2) - [TestUtils](#testutils) - [Example](#example-3) - [Applications](#applications) - [Example](#example-4) - [SavedObjects](#savedobjects) - [Unit Tests](#unit-tests) - [Integration Tests](#integration-tests-1) - [OpenSearch](#opensearch) - [Plugin integrations](#plugin-integrations) - [Preconditions](#preconditions-1) - [Testing dependencies usages](#testing-dependencies-usages) - [Testing components consuming the dependencies](#testing-components-consuming-the-dependencies) - [Testing optional plugin dependencies](#testing-optional-plugin-dependencies) - [Plugin Contracts](#plugin-contracts) ## Strategy 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, OpenSearch 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. ## New concerns in the OpenSearch Dashboards Platform The OpenSearch Dashboards Platform introduces new concepts that legacy plugins did not have concern themselves with. Namely: - **Lifecycles**: plugins now have explicit lifecycle methods that must interop with Core APIs and other plugins. - **Shared runtime**: plugins now all run in the same process at the same time. On the frontend, this is different behavior than the legacy plugins. Developers should take care not to break other plugins when interacting with their enviornment (Node.js or Browser). - **Single page application**: OpenSearch Dashboards's frontend is now a single-page application where all plugins are running, but only one application is mounted at a time. Plugins need to handle mounting and unmounting, cleanup, and avoid overriding global browser behaviors in this shared space. - **Dependency management**: plugins must now explicitly declare their dependencies on other plugins, both required and optional. Plugins should ensure to test conditions where a optional dependency is missing. Simply porting over existing tests when migrating your plugin to the OpenSearch Dashboards Platform will leave blind spots in test coverage. It is highly recommended that plugins add new tests that cover these new concerns. ## 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 { opensearchServiceMock } from 'src/core/server/mocks'; test('my test', async () => { // Setup mock and faked response const opensearchClient = opensearchServiceMock.createScopedClusterClient(); opensearchClient.callAsCurrentUser.mockResolvedValue(/** insert OpenSearch response here */); // Call unit under test with mocked client const result = await myFunction(opensearchClient); // Assert that client was called with expected arguments expect(opensearchClient.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 OpenSearch Dashboards, although not every OpenSearch Dashboards 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 OpenSearch Dashboards 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]() 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 opensearch server to store data and supports additional configuration, you can leverage the Functional Test Runner(FTR) to implement integration tests. FTR bootstraps an opensearch and a OpenSearch Dashboards 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](/test/api_integration/config.js) setup by registering a test file within a [test loader](/test/api_integration/apis/index.js). More about the existing FTR setup in the [contribution guide](/CONTRIBUTING.md#running-specific-opensearch-dashboards-tests) The tests cover: - authenticated / non-authenticated user access (when applicable) - 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 opensearch server or mocks the own methods doing so. Runs tests against real OpenSearch Dashboards server instance. Pros: - runs the real OpenSearch Dashboards instance - tests cross-plugin integration - emulates a real user interaction with the HTTP resources Cons: - faster than FTR because it doesn't run opensearch instance, but still slow - hard to debug - doesn't cover OpenSearch Dashboards CLI logic ###### Example To have access to OpenSearch Dashboards 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 osdTestServer from 'src/core/test_helpers/osd_server'; describe('myPlugin', () => { describe('GET /myPlugin/formatter', () => { let root: ReturnType; beforeAll(async () => { root = osdTestServer.createRoot(); await root.setup(); await root.start(); }, 30000); afterAll(async () => await root.shutdown()); it('validates given text', async () => { const response = await osdTestServer.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 osdTestServer.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 osdTestServer.request .get(root, '/myPlugin/formatter') .query({ text: '