This document provides examples and common usage patterns for working with SmileCdrContainer and SmileHarness in your tests.
The most common way to use SmileCdrContainer is with JUnit 5's @Testcontainers and @Container annotations:
import ca.cdr.test.extensions.SmileCdrContainer;
import ca.cdr.test.app.harness.api.SmileHarness;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
@Testcontainers
class MyTest {
// Define the Docker tag to use
static String smilecdrVersion = "2025.02.R02";
// Create and configure the container
@Container
private static SmileCdrContainer container = new SmileCdrContainer(smilecdrVersion);
// Store the harness for use in tests
private static SmileHarness harness;
@BeforeAll
public static void setup() {
// Get the harness from the container
harness = container.getHarness();
}
@Test
public void testSomething() {
// Use the harness to interact with Smile CDR
}
}
You can specify which version of Smile CDR to use, which remote docker registry to use, and which tag to use:
// Using a default version of Smile CDR that the SmileCdrContainer selects.
SmileCdrContainer container = new SmileCdrContainer();
// Using the default registry with a specific Smile CDR version
SmileCdrContainer container = new SmileCdrContainer("2025.02.R02");
// Using a fully custom Docker registry, image, and tag. This can be useful if you have customized
// the base docker image and host it yourself.
DockerImageName customImage = DockerImageName.parse("my-registry.com/my-image:my-tag");
SmileCdrContainer container = new SmileCdrContainer(customImage);
You can provide a custom properties file to configure the Smile CDR instance:
SmileCdrContainer container = new SmileCdrContainer()
.withPropertiesFile("my-custom-config.properties");
The properties file must be available on the classpath. Please see the tutorial for examples.
By default, the SmileCdrContainer will assume several things to bootstrap connectivity to the Smile CDR container. Specifically it assumes:
ADMIN_JSON module, or 9000 (the default) if it configures none.context_path your properties file configures for it, or at the root.If the configuration you are using does not match the above, you must provide the new information to the Harness Context before building a Smile Harness
You can provide a custom HarnessContext to configure how the harness connects to Smile CDR:
String protocol = "http";
String host = "localhost";
Integer adminJsonPort = 19000;
String adminUsername = "my-admin";
String adminPassword = "my-random-password";
String adminJsonContextPath = "/admin-json";
HarnessContext context = new HarnessContext(
protocol, host, adminJsonPort, adminUsername, adminPassword, adminJsonContextPath);
SmileCdrContainer container = new SmileCdrContainer("2025.02.R02")
.withCustomHarnessContext(context);
The last argument is the context_path of the Admin JSON module. The harness needs it before it can read any other module's configuration, so it cannot discover it. Leave it out (the five-argument constructor) when Admin JSON is served at the root; "" and "/" also mean the root.
You can pre-seed users into the Smile CDR instance using the withPreseedFiles() method. This is useful for setting up test users with specific permissions without having to create them programmatically in each test.
The withPreseedFiles(String... theFiles) method takes a path to a pre-seeding file. The shape of these files can be found in the pre-seeding documentation. The files must be on the classpath, and will be loaded into the classes/config_seeding directory of the Smile CDR container.
You also need to configure the properties file to use the preseeded files.
For examples of how to use this functionality, see the tutorial.
You can enable remote debugging for the Smile CDR instance running in the container using the withDebugEnabled() methods. This allows you to attach a debugger from your IDE to troubleshoot issues or step through code execution.
The simplest way to enable debugging is to call withDebugEnabled() without parameters. This will enable debug mode and suspend container startup until a debugger is attached:
SmileCdrContainer container = new SmileCdrContainer("2025.02.R02")
.withPropertiesFile("my-config.properties")
.withDebugEnabled();
When using this method:
If you want to enable debugging but allow the container to start without waiting for a debugger, you can pass false to the method:
SmileCdrContainer container = new SmileCdrContainer("2025.02.R02")
.withPropertiesFile("my-config.properties")
.withDebugEnabled(false);
When using withDebugEnabled(false):
To connect IntelliJ IDEA debugger to the container:
localhost (or your container host)5005 (always fixed to this port)Important Notes:
withDebugEnabled() (suspend mode), the container will not complete startup until a debugger connectsNote that the cdr-interceptor-starterproject comes with a built-in debugger configuration xml file, which intellij can load.
Once you have a SmileHarness instance, you can use it to interact with the Smile CDR instance in various ways.
// Get a FHIR client
IGenericClient fhirClient = harness.getFhirClient();
// Add authentication if needed
BasicAuthInterceptor authInterceptor = new BasicAuthInterceptor("username", "password");
// Register the interceptor
fhirClient.registerInterceptor(authInterceptor);
// Use the client to interact with FHIR resources
Bundle bundle = fhirClient.search()
.forResource("Patient")
.returnBundle(Bundle.class)
.execute();
// Get an Admin JSON client
AdminJsonRestClient adminClient = harness.getAdminJsonClient();
// Use the client to interact with the Admin API
NodeConfigurations config = adminClient.getNodeConfigurations();
The typed clients above cover the common cases. When you need to assert on the HTTP exchange itself, the harness gives you a request builder that reads the whole exchange into memory and hands back a response you can assert against freely — a status code, a response header, a redirect, a response body.
// Against the FHIR endpoint, authenticated with the harness credentials
harness.fhirRequest("/Patient/123")
.get()
.assertStatus(200);
// Without credentials, to check that the endpoint rejects anonymous callers
harness.fhirAnonymousRequest("/Patient/123")
.get()
.assertStatus(403);
// Against a named FHIR endpoint module
harness.fhirRequest("my_endpoint_module", "/Patient/123").get();
Requests are not limited to FHIR. Any port the harness can reach works, and the harness resolves the container port mapping for you, so you pass the port the module is configured with:
// Admin JSON
harness.adminJsonRequest("/module-status/").get().assertStatus(200);
// Any module, by configured port or by module ID
harness.request(8002, "/packages").get();
harness.request("endpoint_package_registry", "/packages").get();
// Without credentials
harness.anonymousRequest(8000, "/metadata").get().assertStatus(403);
Every builder returns an HttpTestRequest, so you can add headers and choose a verb and body:
Patient patient = new Patient();
patient.setActive(true);
HttpTestResponse response = harness.fhirRequest("/Patient")
.withHeader("Prefer", "return=representation")
.post(patient);
response.assertStatus(201);
assertThat(response.getHeader("Location")).contains("Patient/");
assertThat(response.getBody()).contains("\"active\": true");
To assert how some other user is treated, build the request with an anonymous builder and add that user's credentials, rather than overriding the credentials on fhirRequest(...):
harness.fhirAnonymousRequest("/Patient/123")
.withBasicAuth("limited-user", "password")
.get()
.assertStatus(403);
withBasicAuth(...) replaces the Authorization header, but it does not drop the shared session cookie described in Sessions, Cookies and Harness Lifecycle.
Adding it to fhirRequest(...) therefore sends the new credentials alongside whatever session an earlier request established, which is not the request you meant to describe. The anonymous builders carry no cookies, so they send exactly the credentials you gave them and nothing else.
Each has a named-module and a port form: fhirAnonymousRequest(moduleId, path),
anonymousRequest(port, path) and anonymousRequest(moduleId, path).
Redirects are not followed, so a 302 comes back as a 302 and you can assert on its
Location header rather than on wherever it pointed.
Paths must begin with a slash. The single argument to fhirRequest(...) and
fhirAnonymousRequest(...) is a path, while the single argument to getFhirClient(...),
getHL7V2RestClient(...) and getFhirContext(...) is a module ID — so passing a module ID to a request builder is rejected outright rather than issued as a path against the default module. The one exception is the empty path, which addresses the module's base URL:
harness.fhirRequest("").post(transactionBundle).assertStatus(200);
HttpTestRequest and HttpTestResponse come from HAPI FHIR's hapi-fhir-test-utilities
(ca.uhn.fhir.test.utilities), which cdr-public-test-utils brings in for you.
The HL7V2RestClient allows you to send HL7v2 messages to Smile CDR and process the responses. There are several ways to obtain and use this client.
// Get an HL7v2 REST client for the first discovered HL7v2 endpoint module (port 7000 if there is none)
HL7V2RestClient hl7v2Client = harness.getHL7V2RestClient();
// Get an HL7v2 REST client for a specific port
HL7V2RestClient hl7v2ClientWithPort = harness.getHL7V2RestClient(7001);
// Get an HL7v2 REST client for a specific module ID
HL7V2RestClient hl7v2ClientWithModuleId = harness.getHL7V2RestClient("hl7_endpoint_two");
A client obtained from the harness issues on the harness's HTTP client, so it shares the session described in Sessions, Cookies and Harness Lifecycle. You can also open one directly with HL7V2RestClient.open(baseUrl, username, password), which stands up a connection pool of its own that you then have to close. Either way redirects are not followed, so a 3xx comes back as a
3xx rather than being resolved for you.
You can send an HL7v2 message as a raw string:
// Create a raw HL7v2 message as a string
String rawMessage = "MSH|^~\\&|TestApp|TestFacility|SmileCDR|SmileCDR|20230101120000||ADT^A01^ADT_A01|123456|P|2.5\r" +
"PID|||12345||Smith^John||19800101|M";
// Send the message to the server
Message response = hl7v2Client.sendMessage(rawMessage);
// Verify the response is an acknowledgment
assertThat(response.getName()).contains("ACK");
ADT (Admission, Discharge, Transfer) messages are commonly used in healthcare systems. Here's how to create and send an ADT_A01 message:
// Create a sample ADT_A01 (admission) message
ADT_A01 adt = new ADT_A01();
MSH msh = adt.getMSH();
// Set required MSH fields
msh.getFieldSeparator().setValue("|");
msh.getEncodingCharacters().setValue("^~\\&");
msh.getSendingApplication().getNamespaceID().setValue("TestApp");
msh.getSendingFacility().getNamespaceID().setValue("TestFacility");
msh.getReceivingApplication().getNamespaceID().setValue("SmileCDR");
msh.getReceivingFacility().getNamespaceID().setValue("SmileCDR");
msh.getDateTimeOfMessage().getTime().setValue("20230101120000");
msh.getMessageType().getMessageCode().setValue("ADT");
msh.getMessageType().getTriggerEvent().setValue("A01");
msh.getMessageType().getMessageStructure().setValue("ADT_A01");
msh.getMessageControlID().setValue("123456");
msh.getProcessingID().getProcessingID().setValue("P");
msh.getVersionID().getVersionID().setValue("2.5");
// Set patient information
adt.getPID().getPatientID().getIDNumber().setValue("12345");
adt.getPID().getPatientName(0).getFamilyName().getSurname().setValue("Smith");
adt.getPID().getPatientName(0).getGivenName().setValue("John");
adt.getPID().getDateTimeOfBirth().getTime().setValue("19800101");
adt.getPID().getAdministrativeSex().setValue("M");
// Send the message to the server
Message response = hl7v2Client.sendMessage(adt);
// Process the response
if (response.getName().contains("ACK")) {
// Handle acknowledgment
System.out.println("Message acknowledged");
} else {
// Handle other response types
System.out.println("Received response: " + response.getName());
}
Smile CDR provides a test data helper class that can generate sample HL7v2 messages for you:
// Get the FHIR context from the harness
FhirContext fhirContext = harness.getFhirContext();
// Create a test data helper
Hl7V2TestDataHelper testDataHelper = Hl7V2TestDataHelper.buildDefault(fhirContext);
// Create a sample ADT_A01 message using the helper
ADT_A01 adtA01 = testDataHelper.createAdtA01();
// Send the message to the server
Message response = hl7v2Client.sendMessage(adtA01);
The Hl7V2TestDataHelper provides methods for creating various types of HL7v2 messages with realistic test data, saving you the effort of manually populating all the required fields.
The OutboundSmartClient drives the OAuth 2.0 and SMART on FHIR flows of a SMART Outbound Security module: the browser-style authorization code flow, and the server-side flows a backend service uses.
// The SMART module with ID smart_auth, or else the first one in the node's configuration
OutboundSmartClient smartClient = harness.getOutboundSmartClient();
// The SMART module configured with a specific port
OutboundSmartClient smartClientWithPort = harness.getOutboundSmartClient(9200);
// A specific SMART module
OutboundSmartClient smartClientWithModuleId = harness.getOutboundSmartClient("smart_auth");
A backend service obtains a token with the client credentials grant, then presents it to a FHIR endpoint as a bearer token. The OAuth client must already exist, with the client_credentials
grant type; create it with AdminJsonRestClient.createOAuthClient(...) or seed it.
OutboundSmartClient smartClient = harness.getOutboundSmartClient();
// Authenticate with a client secret (sent with HTTP Basic authentication)
AccessTokenResponse token = smartClient.clientCredentials("my-client", "my-secret", "system/*.read");
// Or with a signed JWT client assertion, as a SMART Backend Services client does.
// You build and sign the JWT; the client only sends it.
AccessTokenResponse assertedToken =
smartClient.clientCredentialsWithAssertion("my-client", signedJwt, "system/*.read");
// Use the token against a FHIR endpoint
IGenericClient fhirClient = harness.getFhirClient("fhir_endpoint", token.accessToken());
Patient patient = fhirClient.read().resource(Patient.class).withId("123").execute();
// Or build a raw request carrying it
harness.fhirBearerRequest("fhir_endpoint", "/Patient/123", token.accessToken())
.get()
.assertStatus(200);
AccessTokenResponse exposes accessToken(), tokenType(), expiresIn(), scope(), idToken()
and refreshToken(), plus raw() for any other member of the token response.
The bearer builders carry neither the harness credentials nor its session cookie, so the token is the only thing that authenticates the request.
// Ask whether a token is still active, authenticating as a client allowed to introspect it
JsonNode introspection = smartClient.introspect(token.accessToken(), "my-client", "my-secret");
assertThat(introspection.get("active").asBoolean()).isTrue();
// Read the OpenID Connect discovery document
JsonNode openIdConfiguration = smartClient.openIdConfiguration();
// Exchange a refresh token; returns the token response as a JSON string
String refreshed = smartClient.refreshToken("my-client", "my-secret", refreshToken);
AccessTokenResponse refreshedToken = AccessTokenResponse.fromJson(new ObjectMapper().readTree(refreshed));
String clientId = "test-client-id";
String clientSecret = "test-client-secret";
String redirectUrl = "https://client.example.org/cb";
String[] scopes = {"patient/*.read", "openid"};
String state = "test-state-12345";
// Log in as a user, approve, and exchange the code for an access token in one call
String accessToken = smartClient.performAuthorizationCodeFlow(
clientId, redirectUrl, scopes, state, clientSecret, "some-user", "some-password");
// Or step by step
String authCode = smartClient.getAuthorizationCode(clientId, redirectUrl, scopes, state, "some-user", "some-password");
String token = smartClient.exchangeCodeWithSecret(clientId, clientSecret, authCode, redirectUrl);
// Utility methods
String scopeString = smartClient.buildScopeString(scopes);
String extractedCode = OutboundSmartClient.extractCodeFromUrl("https://example.com/cb?code=abc123&state=xyz");
The CdsHooksClient calls the three endpoints of a CDS Hooks endpoint module: discovery, service invocation and feedback.
// The first CDS Hooks endpoint module in the node's configuration, authenticated with the harness credentials
CdsHooksClient cdsHooksClient = harness.getCdsHooksClient();
// GET {base}/cds-services
JsonNode services = cdsHooksClient.discovery();
// POST {base}/cds-services/{id}
ObjectNode request = new ObjectMapper().createObjectNode()
.put("hook", "patient-view")
.put("hookInstance", UUID.randomUUID().toString());
request.putObject("context").put("userId", "Practitioner/1").put("patientId", "123");
JsonNode response = cdsHooksClient.invoke("my-service", request);
// POST {base}/cds-services/{id}/feedback
cdsHooksClient.feedback("my-service", feedbackRequest);
CDS Hooks services are often exposed without authentication. To call one that way, open a client of your own with CdsHooksClient.openAnonymous(baseUrl), and close it when you are done.
A harness owns one HTTP client: one connection pool and one cookie store. Every client it hands out issues on that client — the request builders, the Admin JSON client, the outbound SMART client, the package registry client and the HL7v2 client alike. The shared cookie store is what makes a multi-request login flow work, so a SMART login driven through getOutboundSmartClient() is visible to a later fhirRequest(...).
Cookies are scoped to the host and not to the port, so a session established against one module's port travels to the others. To start a fresh session, clear them:
harness.clearCookies();
fhirAnonymousRequest(...) and anonymousRequest(...) are the exception: they send no cookies and store none. That is what makes them usable for the assertion they exist for — that a caller with no credentials is turned away — since a request on the shared session would be answered as whoever logged in last:
harness.getOutboundSmartClient().loginWithPassword("some-user", "some-password"); // establishes a session
harness.fhirAnonymousRequest("/Patient/123").get().assertStatus(403); // still anonymous
The FHIR clients returned by getFhirClient(...) are HAPI IGenericClients with their own transport, and are not covered by any of this.
SmileHarness is AutoCloseable. When you obtain it from a SmileCdrContainer you do not need to close it yourself — the container closes it when it stops, and if you close it anyway the next
getHarness() builds a replacement rather than handing back the closed one. Close it yourself only when you built the harness directly:
try (SmileHarness harness = new LocalhostSmileHarness(context)) {
// ...
}
container.getHarness() caches the harness, and a harness reads the node's module configuration once, when it is built — module IDs, ports and FHIR versions all come from that snapshot, which is why a request builder costs no Admin JSON round trip. If a test adds, removes or re-ports a module through the Admin JSON API, tell the container to rebuild the harness — otherwise the next call returns the same pre-change snapshot:
harness.getAdminJsonClient().moduleCreate(/* ... */);
harness = container.refreshHarness();
refreshHarness() returns the replacement and caches it, so a later getHarness() gives you the same one. It closes the previous harness, ending any session it held and invalidating any reference still pointing at it — use the harness it hands back rather than the one you were holding.
withCustomHarnessContext(...) discards the cached harness in the same way, so a context set after the first getHarness() still takes effect.
getFhirContext(...) is the exception: it queries the node each time it is called, so it reflects a configuration change without a refresh.
// Get the FHIR context from the first found persistence module
FhirContext fhirContext = harness.getFhirContext();
// Get the FHIR context from a named persistence module
String persistenceModuleId = "my_persistence_r4_module";
FhirContext fhirContext = harness.getFhirContext(persistenceModuleId);
When working with Docker containers, port mapping is important to understand. The SmileCdrContainer maps container ports to host ports, and the SmileHarness handles this mapping for you:
// Get a FHIR client for a specific port
IGenericClient fhirClient = harness.getFhirClient(8000);
// The harness automatically maps the container port to the host port
String serverBase = fhirClient.getServerBase();
// serverBase will be something like "http://localhost:32789"
A module configured with a context_path is served beneath it, and the harness builds every URL with it: the FHIR clients, the protocol clients and the request builders alike. Paths you pass to a request builder are relative to the module's context path.
module.fhir_endpoint.config.port=8000
module.fhir_endpoint.config.context_path=/fhir-request
harness.getFhirClient().getServerBase(); // http://localhost:32789/fhir-request
harness.fhirRequest("/metadata").get(); // GET http://localhost:32789/fhir-request/metadata
The harness reads each module's context_path from the node's configuration, except for the Admin JSON module's, which it needs before it can read that configuration. SmileCdrContainer reads that one from your properties file; a harness you build yourself takes it from the HarnessContext (see Using a Custom Harness Context).
Only context_path decides where a module is served. base_url.fixed changes the URLs a module writes into its responses, not the path it listens on, so the harness does not use it. A port that no module is configured with, passed to request(port, path) or anonymousRequest(port, path), is addressed at its root.
You can also get the port mapping directly from the container:
Integer mappedFhirPort = container.getMappedPort(8000);
If your Smile CDR instance has multiple modules of the same type (e.g., multiple FHIR endpoints),
the no-argument accessors choose one by a fixed rule: the module with the conventional ID
(fhir_endpoint for getFhirClient(), getSuperuserFhirClient() and fhirRequest(path);
smart_auth for getOutboundSmartClient()) if there is one, and otherwise the first module of that type in the node's configuration. To use a different one, specify it:
// Get a FHIR client for a specific module by ID
IGenericClient fhirClient = harness.getFhirClient("my_endpoint_module");
// Get a FHIR client for a specific module by port
IGenericClient fhirClient = harness.getFhirClient(8010);
// Get a FHIR context for a specific module by ID
FhirContext fhirContext = harness.getFhirContext("my_persistence_module");
For a step-by-step guide to writing tests with SmileCdrContainer and SmileHarness, see the tutorial.
For detailed information about the clients available through SmileHarness, see Available Clients.
For recommendations on effective testing with SmileCdrContainer and SmileHarness, see Best Practices.