4. Test the Patient Provider

On the previous page, you implemented the Patient read and search, and removed the unit tests that expected HTTP 501. On this page, you add tests for the new code: unit tests for the mapper and the provider, and integration tests that call your deployed service. The integration tests that came with the archetype still expect HTTP 501, so you update them too.

How We Test

Our testing philosophy combines unit and integration testing to measure the coverage we have around our endpoints. Considered together like this, test coverage becomes a meaningful metric of the state of our services. We use real services as much as possible, and mocks only when necessary, usually only to stand in for external systems, as WireMock does for the Mobile MVI Service here. Integration tests come first; unit tests are for logic that is easier to test in isolation, such as the mapper, or for cases that are hard to reach from an integration test.

JUnit, Mockito, and AssertJ

The tests in this guide use three libraries. If you have used them before, skip ahead to Test the Mapper.

JUnit 5 runs the tests:

  • @Test marks a test method. A test passes when it finishes without an exception.

  • @BeforeEach marks a method that runs before every test, for example to create the object under test.

  • @ParameterizedTest runs a test once for each row of data, for example from a @CsvSource({"M, MALE", "F, FEMALE"}). Each row counts as one test.

AssertJ checks the results. Every assertion starts with assertThat(actual), followed by what you expect:

Assertion Passes when

assertThat(x).isEqualTo(y)

x equals y

assertThat(list).hasSize(2), .isEmpty()

The list has two elements, or none

assertThat(list).singleElement().satisfies(e → assertThat(…​)…​)

The list has exactly one element, and the nested assertions pass for it

assertThat(text).contains("…​"), .doesNotContain("…​")

The text contains, or doesn’t contain, the given text

assertThatThrownBy) → call(.isInstanceOf(SomeException.class).hasMessageContaining("…​")

call() throws that exception, with that text in its message

Mockito stands in for the objects your class depends on, so a unit test doesn’t need the real Mobile MVI Service:

  • With @ExtendWith(MockitoExtension.class) on the test class, a field annotated with @Mock gets a mock: an object whose methods return null, an empty Optional, or 0, until you tell them otherwise.

  • when(mock.method(args)).thenReturn(value) tells the mock what to return. This is called stubbing.

  • eq(value) and any() match arguments. If you use one for an argument, use them for all the arguments of that call.

  • The tests use strict stubs: a test fails if it stubs a call that never happens. This catches tests that don’t test what they claim to.

A Worked Example

Here is the first read test of PatientProviderTest, which you add below. It follows the pattern of every unit test in this guide, arrange, act, assert:

    @Test
    void read_staff() {
        // Arrange: a staff user, and an MVI that knows the patient
        givenUser(JwtTestUtils.V2.createStaffUser());
        when(mviClient.getPerson(eq(OTHER_ICN), any())).thenReturn(Optional.of(new Person().lastName("THIRTYTWO")));

        // Act: read the patient
        final Patient patient = provider.read(requestDetails, new IdType(OTHER_ICN));

        // Assert: the Patient has the ICN as its id, and the name from MVI
        assertThat(patient.getIdPart()).isEqualTo(OTHER_ICN);
        assertThat(patient.getNameFirstRep().getFamily()).isEqualTo("THIRTYTWO");
    }
  • givenUser is a helper method in the test class. It puts the JWT user that PatientAccess reads into the mocked request.

  • getPerson takes the ICN and the caller’s JWT. The test doesn’t care which JWT is passed on, so it matches the JWT with any(), and therefore the ICN with eq(…​).

  • The test checks what matters about the result, not every field: the mapper has its own tests.

For a test that expects an exception, create everything the call needs before the lambda, so that only the call that should throw is inside it:

        final IdType id = new IdType("not-an-icn");

        assertThatThrownBy(() -> provider.read(requestDetails, id))
                .isInstanceOf(InvalidRequestException.class);

Test the Mapper

Create the test package mapper (in src/test/java/gov/va/mobile/fhirstarter/v1) and a test class named PatientMapperTest. The first two tests are complete; implement the other two:

package gov.va.mobile.fhirstarter.v1.mapper;

import ca.uhn.fhir.context.FhirContext;
import gov.va.mobile.mvi.v1.model.Address;
import gov.va.mobile.mvi.v1.model.Person;
import org.hl7.fhir.r4.model.ContactPoint;
import org.hl7.fhir.r4.model.Enumerations.AdministrativeGender;
import org.hl7.fhir.r4.model.HumanName;
import org.hl7.fhir.r4.model.Patient;
import org.junit.jupiter.api.Test;

import static gov.va.mobile.security.FhirSystems.ICN_SYSTEM;
import static org.assertj.core.api.Assertions.assertThat;

/**
 * Unit tests for {@link PatientMapper}.
 */
class PatientMapperTest {

    /**
     * ICN of PATIENT FIFTEEN.
     */
    private static final String ICN = "10115V737384";

    /**
     * The mapper under test.
     */
    private final PatientMapper mapper = new PatientMapper();

    /**
     * Verify every MVI field the service uses is mapped to the Patient.
     */
    @Test
    void toPatient() {
        final Patient patient = mapper.toPatient(ICN, fifteen());

        assertThat(patient.getIdPart()).isEqualTo(ICN);
        assertThat(patient.getIdentifierFirstRep().getSystem()).isEqualTo(ICN_SYSTEM);
        assertThat(patient.getIdentifierFirstRep().getValue()).isEqualTo(ICN);

        final HumanName name = patient.getNameFirstRep();
        assertThat(name.getUse()).isEqualTo(HumanName.NameUse.OFFICIAL);
        assertThat(name.getFamily()).isEqualTo("FIFTEEN");
        assertThat(name.getGivenAsSingleString()).isEqualTo("PATIENT");

        assertThat(patient.getGender()).isEqualTo(AdministrativeGender.MALE);
        assertThat(patient.getBirthDateElement().getValueAsString()).isEqualTo("1953-04-01");
        assertThat(patient.getTelecomFirstRep().getSystem()).isEqualTo(ContactPoint.ContactPointSystem.PHONE);
        assertThat(patient.getTelecomFirstRep().getValue()).isEqualTo("(123)234-5678");

        final org.hl7.fhir.r4.model.Address address = patient.getAddressFirstRep();
        assertThat(address.getLine().getFirst().getValue()).isEqualTo("1111 Any Street");
        assertThat(address.getCity()).isEqualTo("Any Town");
        assertThat(address.getState()).isEqualTo("WV");
        assertThat(address.getPostalCode()).isEqualTo("99998-3414");
        assertThat(address.getCountry()).isEqualTo("USA");
    }

    /**
     * Verify the SSN that MVI returns doesn't appear anywhere in the Patient.
     */
    @Test
    void toPatient_doesNotMapSsn() {
        final Person person = fifteen().ssn("666000015");

        final Patient patient = mapper.toPatient(ICN, person);

        final String json = FhirContext.forR4Cached().newJsonParser().encodeResourceToString(patient);
        assertThat(json).doesNotContain("666000015");
    }

    /**
     * Verify the MVI gender codes are mapped to FHIR genders: M, F, another code, and a missing code.
     */
    @Test
    void toGender() {
        // TODO: implement
    }

    /**
     * Verify an invalid MVI birth time results in no birth date.
     */
    @Test
    void toBirthDate_invalid() {
        // TODO: implement
    }

    /**
     * Create the MVI demographics of PATIENT FIFTEEN, as returned by the WireMock stub.
     *
     * @return the demographics
     */
    private static Person fifteen() {
        return new Person()
                .lastName("FIFTEEN")
                .firstName("PATIENT")
                .birthTime("19530401")
                .gender("M")
                .telecom("(123)234-5678")
                .address(new Address()
                                 .street("1111 Any Street")
                                 .city("Any Town")
                                 .state("WV")
                                 .zipCode("99998-3414")
                                 .country("USA"));
    }
}
  • toPatient_doesNotMapSsn encodes the whole Patient as JSON, so it catches the SSN wherever a future change might put it.

  • For toGender, consider a @ParameterizedTest with a @CsvSource, for example "M, MALE", and call PatientMapper.toGender(code) directly. Cover five codes: M, F, another code such as X (UNKNOWN), a blank code (""), and a missing code (null). The last two both map to null. In a @CsvSource, "'', " passes a blank code and "," passes null for both columns.

  • For toBirthDate_invalid, call PatientMapper.toBirthDate with a birth time that has the right format but an impossible date, such as 19531345.

Update the Provider Unit Tests

On page 3, you gave PatientProviderTest a mocked MviClient, and removed the tests that expected HTTP 501. Now add tests for the read and search. Make these changes to PatientProviderTest.java:

  1. Add the following search tests after the getResourceType test:

        @Test
        void search_staff() {
            givenUser(JwtTestUtils.V2.createStaffUser());
            when(mviClient.getPerson(eq(OTHER_ICN), any())).thenReturn(Optional.of(new Person().lastName("THIRTYTWO")));
    
            final List<Patient> patients = provider.search(requestDetails, new TokenParam(ICN_SYSTEM, OTHER_ICN));
    
            assertThat(patients).singleElement()
                                .satisfies(patient -> assertThat(patient.getIdPart()).isEqualTo(OTHER_ICN));
        }
    
        @Test
        void search_veteranSelfWithoutSystem() {
            givenUser(JwtTestUtils.V2.createVeteranUser(VETERAN_ICN));
            when(mviClient.getPerson(eq(VETERAN_ICN), any())).thenReturn(Optional.of(new Person().lastName("FIFTEEN")));
    
            final List<Patient> patients = provider.search(requestDetails, new TokenParam(VETERAN_ICN));
    
            assertThat(patients).hasSize(1);
        }
    
        @Test
        void search_notFound() {
            givenUser(JwtTestUtils.V2.createStaffUser());
            when(mviClient.getPerson(eq(OTHER_ICN), any())).thenReturn(Optional.empty());
    
            final List<Patient> patients = provider.search(requestDetails, new TokenParam(ICN_SYSTEM, OTHER_ICN));
    
            assertThat(patients).isEmpty();
        }
  2. Add the following read tests just before the givenUser method. read_staff is the worked example from JUnit, Mockito, and AssertJ; implement the other two read tests:

        /**
         * A staff user reads a patient that MVI knows.
         */
        @Test
        void read_staff() {
            givenUser(JwtTestUtils.V2.createStaffUser());
            when(mviClient.getPerson(eq(OTHER_ICN), any())).thenReturn(Optional.of(new Person().lastName("THIRTYTWO")));
    
            final Patient patient = provider.read(requestDetails, new IdType(OTHER_ICN));
    
            assertThat(patient.getIdPart()).isEqualTo(OTHER_ICN);
            assertThat(patient.getNameFirstRep().getFamily()).isEqualTo("THIRTYTWO");
        }
    
        /**
         * A staff user reads a patient that MVI has no record of: ResourceNotFoundException.
         */
        @Test
        void read_notFound() {
            // TODO: implement
        }
    
        /**
         * A veteran reads the record of another patient: ForbiddenOperationException.
         */
        @Test
        void read_veteranOther() {
            // TODO: implement
        }
    
        @Test
        void read_invalidIcn() {
            final IdType id = new IdType("not-an-icn");
    
            assertThatThrownBy(() -> provider.read(requestDetails, id))
                    .isInstanceOf(InvalidRequestException.class);
        }
  3. Add these imports:

    import gov.va.mobile.mvi.v1.model.Person;
    
    import java.util.List;
    import java.util.Optional;
    
    import static org.mockito.ArgumentMatchers.any;
    import static org.mockito.ArgumentMatchers.eq;

The other tests stay as they are: the access rules haven’t changed.

Stuck, or want to check your work? Compare your file with fhir-starter-service/src/test/java/gov/va/mobile/fhirstarter/v1/resource/PatientProviderTest.java in the "FHIR starter page 4" commit of the reference branch; see When You Get Stuck.
  • Follow read_staff. Pass only the ICN to IdType: the build’s PMD check fails a class that repeats the same string literal four times or more, such as "Patient".

  • read_notFound expects a ResourceNotFoundException. Add import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException; when you use it.

  • Mockito’s strict stubs fail a test that sets up a when(…​) it never uses. In read_veteranOther, the provider denies access before it calls MVI, so don’t stub mviClient there.

Update the Integration Tests

The generated PatientResourceITCase expected HTTP 501. Change it to test against your WireMock stubs:

  1. Replace the class Javadoc with the following:

    /**
     * Integration tests for the Patient resource provider, covering the role based access rules of the service.
     *
     * <p>The patient data comes from the WireMock stubs of the Mobile MVI Service in
     * {@code kubernetes/components/dev/wiremock-data/mvi}.
     */
  2. Add a constant for the patient that MVI has no record of, after OTHER_ICN:

        /**
         * ICN of a patient that MVI has no record of.
         */
        private static final String UNKNOWN_ICN = "1012845331V153043";
  3. Replace the searchByIcn_staff and searchByIcn_veteranSelf tests with the following. The third test, searchByIcn_notFound, is new:

        /**
         * Verify a staff user may search for any patient.
         */
        @Test
        void searchByIcn_staff() {
            final IGenericClient client = newClient(serviceUrl, JwtTestUtils.V2.createStaffJwt());
    
            final Bundle bundle = searchByIcn(client, OTHER_ICN).execute();
    
            assertThat(bundle.getEntry()).singleElement()
                                         .satisfies(entry -> assertThat(((Patient) entry.getResource()).getNameFirstRep().getFamily())
                                                 .isEqualTo("THIRTYTWO"));
        }
    
        /**
         * Verify a veteran user may search for their own records.
         */
        @Test
        void searchByIcn_veteranSelf() {
            final IGenericClient client = newClient(serviceUrl, JwtTestUtils.V2.createVeteranJwt(VETERAN_ICN));
    
            final Bundle bundle = searchByIcn(client, VETERAN_ICN).execute();
    
            assertThat(bundle.getEntry()).hasSize(1);
        }
    
        /**
         * Verify a search for a patient that MVI has no record of returns an empty Bundle.
         */
        @Test
        void searchByIcn_notFound() {
            final IGenericClient client = newClient(serviceUrl, JwtTestUtils.V2.createStaffJwt());
    
            final Bundle bundle = searchByIcn(client, UNKNOWN_ICN).execute();
    
            assertThat(bundle.getEntry()).isEmpty();
        }
  4. Replace the readById_staff test with the following, which also adds a readById helper method, and implement the four TODO tests:

        /**
         * Verify a staff user may read any patient.
         */
        @Test
        void readById_staff() {
            final IGenericClient client = newClient(serviceUrl, JwtTestUtils.V2.createStaffJwt());
    
            final Patient patient = readById(client, OTHER_ICN).execute();
    
            assertThat(patient.getIdPart()).isEqualTo(OTHER_ICN);
            assertThat(patient.getNameFirstRep().getFamily()).isEqualTo("THIRTYTWO");
        }
    
        /**
         * Verify a veteran user may read their own record.
         */
        @Test
        void readById_veteranSelf() {
            // TODO: implement
        }
    
        /**
         * Verify a veteran user may not read the record of another patient.
         */
        @Test
        void readById_veteranOther() {
            // TODO: implement
        }
    
        /**
         * Verify reading a patient that MVI has no record of results in HTTP 404.
         */
        @Test
        void readById_notFound() {
            // TODO: implement
        }
    
        /**
         * Verify a logical id that isn't a valid ICN is rejected.
         */
        @Test
        void readById_invalidIcn() {
            // TODO: implement
        }
    
        /**
         * Build a Patient read ({@code GET [base]/Patient/<icn>}).
         *
         * @param client the FHIR client to read with
         * @param icn the ICN of the patient, which is the logical id of the Patient
         * @return the read, ready to execute
         */
        private static IReadExecutable<Patient> readById(final IGenericClient client, final String icn) {
            return client.read().resource(Patient.class).withId(icn);
        }
  5. Remove the import of NotImplementedOperationException, which is no longer used, and add import static org.assertj.core.api.Assertions.assertThat; with the other static imports.

The searchByIcn_veteranOther, searchByIcn_invalidIcn, and searchByIcn_unauthenticated tests stay as they are.

Stuck, or want to check your work? Compare your file with fhir-starter-service/src/test/java/gov/va/mobile/fhirstarter/v1/PatientResourceITCase.java in the "FHIR starter page 4" commit of the reference branch; see When You Get Stuck.
  • The integration tests use the HAPI FHIR client (IGenericClient), created by newClient in BaseITCase with a JWT from JwtTestUtils. The client throws the HAPI exception that matches the HTTP status of an error response, for example ForbiddenOperationException for HTTP 403.

  • JwtTestUtils.V2.createVeteranJwt(icn) puts the ICN in the subject of the JWT, as jwtgen does, so it can read that patient’s records and no others.

  • For readById_notFound, use readById(client, UNKNOWN_ICN) and expect a ResourceNotFoundException whose message contains No patient was found. Add its import when you use it. Compare it with searchByIcn_notFound: searching for a patient that doesn’t exist isn’t an error, but reading one is.

  • For readById_invalidIcn, read "not-an-icn" and expect an InvalidRequestException.

Check the MVI Health Indicator

In HealthCheckITCase, check that the mvi health indicator is up, before the overall status:

        assertActuatorHealth()
                .withBaseUrl(actuatorUrl)
                .hasComponentsUp("mvi")
                .isUp();

Run the Build

The build measures the code coverage of the unit tests and the integration tests together, and fails if line or branch coverage drops below 80% (the thresholds are in pom.xml). After a full build, open target/site/jacoco-aggregate/index.html in a browser to see the coverage details.

Run the full build to make sure all tests and code quality checks pass:

./mvnw clean install -Pwith-skaffold

For a quicker check while you work, use ./mvnw verify; see Your Development Loop.

If you have test failures and need to debug, refer to the debug guide: start the service with skaffold debug, and run the integration tests from your IDE with breakpoints in your code.

Commit Your Work

The build passes again, so push your work from this page and the previous one:

git add .
git commit -m "Test the Patient read and search"
git push

You’re Done When

  • PatientMapperTest, PatientProviderTest, and PatientResourceITCase have no TODO tests left

  • All tests pass with ./mvnw clean install -Pwith-skaffold, and coverage is at least 80%

  • Your work is committed and pushed

Continue to 5. Add FHIR Exception Handling to handle failures of the Mobile MVI Service.