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.
beforeAll(() => {
indexStructureDefinitionBundle(readJson('fhir/r4/profiles-types.json') as Bundle);
indexStructureDefinitionBundle(readJson('fhir/r4/profiles-resources.json') as Bundle);
indexSearchParameterBundle(readJson('fhir/r4/search-parameters.json') as Bundle<SearchParameter>);
indexSearchParameterBundle(readJson('fhir/r4/search-parameters-medplum.json') 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:
- Create a Medplum
MockClient
- Create mock resources
- Invoke the handler function
- 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.
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, { 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, { 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');
}
}
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, { 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, { 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.