Skip to main content

Unit Testing Bots

Unit testing your Bot code is crucial to ensuring accurate data and workflows. This guide will go over the most common unit testing patterns.

Medplum provides the MockClient class to help unit test Bots on your local machine. You can also see a reference implementation of simple bots with tests in our Medplum Demo Bots repo.

Set up your test framework

The first step is to set up your test framework in your Bots package. Medplum Bots will should work with any typescript/javascript test runner, and the Medplum team has tested our bots with jest and vitest. Follow the instructions for your favorite framework to set up you package.

Next you'll want to index the FHIR schema definitions. To keep the client small, the MockClient class only ships with a subset of the FHIR schema. Index the full schema as shown below, either in a beforeAll function or setup file, to make sure your test queries work.

import { SEARCH_PARAMETER_BUNDLE_FILES, readJson } from '@medplum/definitions';
beforeAll(() => {
indexStructureDefinitionBundle(readJson('fhir/r4/profiles-types.json') as Bundle);
indexStructureDefinitionBundle(readJson('fhir/r4/profiles-resources.json') as Bundle);
for (const filename of SEARCH_PARAMETER_BUNDLE_FILES) {
indexSearchParameterBundle(readJson(filename) as Bundle<SearchParameter>);
}
});

Our Medplum Demo Bots repo also contains recommended eslintrc, tsconfig, and vite.config settings for a faster developer feedback cycle.

Write your test file

After setting up your framework, you're ready to write your first test file! The most common convention is to create a single test file per bot, named <botname>.test.ts.

You will need to import your bot's handler function, in addition to the other imports required by your test framework. You'll call this handler from each one of your tests.

import { handler } from './my-bot';

Write your unit test

Most bot unit tests follow a common pattern:

  1. Create a Medplum MockClient
  2. Create mock resources
  3. Invoke the handler function
  4. Query mock resources and assert test your test criteria

The finalize-report tests are a great example of this pattern.

Create a MockClient

The Medplum MockClient class extends the MedplumClient class, but stores resources in local memory rather than persisting them to the server. This presents a type-compatible interface to the Bot's handler function, which makes it ideal for unit tests.

const medplum = new MockClient();

We recommend creating a MockClient at the beginning of each test, to avoid any cross-talk between tests.

Caution

The MockClient does not yet perfectly replicate the functionality of the MedplumClient class, as this would require duplicating the entire server codebase. Some advanced functionality does not yet behave the same between MockClient and MedplumClient, including:

  • medplum.graphql
  • FHIR $ operations

The Medplum team is working on bringing these features to parity as soon as possible.

Create test data

Most tests require setting up some resources in the mock environment before running the Bot. You can use createResource() and updateResource() to add resources to your mock server, just as you would with a regular MedplumClient instance.

The finalize-report bot from Medplum Demo Bots provides a good example. Each test sets up a Patient, an Observation, and a DiagnosticReport before invoking the bot.

Example: Create Resources
//Create the Patient
const patient: Patient = await medplum.createResource({
resourceType: 'Patient',
name: [
{
family: 'Smith',
given: ['John'],
},
],
});

// Create an observation
const observation: Observation = await medplum.createResource({
resourceType: 'Observation',
status: 'preliminary',
subject: createReference(patient),
code: {
coding: [
{
system: LOINC,
code: '39156-5',
display: 'Body Mass Index',
},
],
text: 'Body Mass Index',
},
valueQuantity: {
value: 24.5,
unit: 'kg/m2',
system: UCUM,
code: 'kg/m2',
},
});

// Create the Report
const report: DiagnosticReport = await medplum.createResource({
resourceType: 'DiagnosticReport',
status: 'preliminary',
code: { text: 'Body Mass Index' },
result: [createReference(observation)],
});

Creating Rich Test Data in Batches

Creating individual test resources can be time consuming and tedious, so the MockClient also offers the ability to use batch requests to set up sample data. See the FHIR Batch Requests guide for more details on batch requests.

The MockClient offers the executeBatch helper function to easily execute batch requests and works in the same way that the standard Medplum Client does.

The below example is from the find matching patients bot tests. Additionally, you can view the patient data here.

Example: Creating a large set of patient data with a batch request
// import a Bundle of test data from 'patient-data.json'
import patientData from './patient-data.json';
// Load the sample data from patient-data.json
beforeEach<TestContext>(async (context) => {
context.medplum = new MockClient();
await context.medplum.executeBatch(patientData as Bundle);
});

test<TestContext>('Created RiskAssessment', async ({ medplum }) => {
// Read the patient. The `medplum` mock client has already been pre-populated with test data in `beforeEach`
const patients = await medplum.searchResources('Patient', { given: 'Alex' });

await handler(medplum, {
bot: { reference: 'Bot/123' },
input: patients?.[0] as Patient,
contentType: ContentType.FHIR_JSON,
secrets: {},
});

// We expect two risk assessments to be created for the two candidate matches
const riskAssessments = await medplum.searchResources('RiskAssessment');
expect(riskAssessments.length).toBe(2);
expect(riskAssessments.every((assessment) => resolveId(assessment.subject) === patients[0].id));
});

In this example we create the MockClient, then the test data by calling executeBatch in the beforeEach function. The beforeEach function is an optimization that will run before each test, so you do not need to create the data as a part of every test.

Once you have created your data, you can write your tests. The above example uses test contexts, a feature of the Vitetest framework. It allows you to pass in the MockClient medplum as part of your test context. This test is checking that a RiskAssessment was created when looking for potential duplicate patients.

Using the Medplum CLI

If you have a dev project that already has rich data, you can use the Medplum CLI to easily convert this data into test data.

The Medplum CLI offers the optional --as-transaction flag when using the medplum get command. A GET request returns a Bundle with type=searchset, but this flag will convert it to type=transaction.

Example: Get a patient and all encounters that reference them as a transaction
medplum get --as-transaction 'Patient?name=Alex&_revinclude=Encounter:patient'

This example searches for all Patient resources named 'Alex'. It also uses the _revinclude parameter to search for all Encounter resources that reference those patients.

A transaction Bundle can be used directly in a batch request, and can be passed as an argument to executeBatch on your MockClient. This allows you to easily create test resources from already existing data.

Cloning an Existing Projects

If you want to clone an existing project into a new environment, you can use the $clone operation. For more details see the Projects guide.

Invoke your Bot

After setting up your mock resources, you can invoke your bot by calling your bot's handler function. See the "Bot Basics" tutorial for more information about the arguments to handler

// Invoke the Bot
const contentType = 'application/fhir+json';
await handler(medplum, {
bot: { reference: 'Bot/123' },
input: report,
contentType,
secrets: {},
});

Query the results

Most of the time, Bots will create or modify resources on the Medplum server. To test your bot, you can use your MockClient to query the state of resources on the server, just as you would with a MedplumClient in production.

To check the bot's response, simply check the return value of your handler function.

The after running the Bot, the finalize-report bot's tests read the updated DiagnosticReport and Observation resources to confirm their status.

Example: Query the results
// Check the output by reading from the 'server'
// We re-read the report from the 'server' because it may have been modified by the Bot
const checkReport = await medplum.readResource('DiagnosticReport', report.id as string);
expect(checkReport.status).toBe('final');

// Read all the Observations referenced by the modified report
if (checkReport.result) {
for (const observationRef of checkReport.result) {
const checkObservation = await medplum.readReference(observationRef);
expect(checkObservation.status).toBe('final');
}
}
A note on idempotency

Many times, you'd like to make sure your Bot is idempotent. This can be accomplished by calling your bot twice, and using your test framework's spyOn functions to ensure that no resources are created/updated in the second call.

Example: Idempotency test
// Invoke the Bot for the first time
const contentType = 'application/fhir+json';
await handler(medplum, {
bot: { reference: 'Bot/123' },
input: report,
contentType,
secrets: {},
});

// Read back the report
const updatedReport = await medplum.readResource('DiagnosticReport', report.id as string);

// Create "spys" to catch calls that modify resources
const updateResourceSpy = vi.spyOn(medplum, 'updateResource');
const createResourceSpy = vi.spyOn(medplum, 'createResource');
const patchResourceSpy = vi.spyOn(medplum, 'patchResource');

// Invoke the bot a second time
await handler(medplum, {
bot: { reference: 'Bot/123' },
input: updatedReport,
contentType,
secrets: {},
});

// Ensure that no modification methods were called
expect(updateResourceSpy).not.toHaveBeenCalled();
expect(createResourceSpy).not.toHaveBeenCalled();
expect(patchResourceSpy).not.toHaveBeenCalled();

Using the Medplum CLI

If you have a dev project that already has rich data, you can use the Medplum CLI to easily convert this data into test data.

The Medplum CLI offers the optional --as-transaction flag when using the medplum get command. A GET request returns a Bundle with type=searchset, but this flag will convert it to type=transaction.

Example: Get a patient and all encounters that reference them as a transaction

// medplum get --as-transaction 'Patient?name=Alex&_revinclude=Encounter:patient'

This example searches for all Patient resources named 'Alex'. It also uses the _revinclude parameter to search for all Encounter resources that reference those patients.

A transaction Bundle can be used directly in a batch request, and can be passed as an argument to executeBatch on your MockClient. This allows you to easily create test resources from already existing data.