(Integration) Testing

In an earlier section of this tutorial we looked at unit testing, but arguably integration tests are at least as important, probably more so, because they exercise the entire application from an end-users perspective, rather than an individual part.

We’ll look at integration tests shortly, but first let’s look once more to improve our fixture scripts.

An improved Fixture Script

While fixture scripts are great for prototyping and demos, they can also be used for integration tests: they represent the "given" of some scenario.

However, our RecreateOwnersAndPets fixture script currently has too many responsibilities, both defining what data to set up and also how to set up that data.

If we split out these responsibilities, it’ll make it easier to write integration tests in the future.

Solution

git checkout tags/270-an-improved-fixture-script
mvn clean package jetty:run

Exercise

First, the "know-how-to" responsibility:

  • create OwnerBuilderScript. This knows "how to" create an Owner and their Pets using the various injected domain services. Using the business logic of the app to setup the data (as opposed to inserting directly in the underlying database tables) means that the fixture will remain valid even if the implementation changes:

    @lombok.experimental.Accessors(chain = true)
    public class OwnerBuilderScript extends BuilderScriptAbstract<Owner, OwnerBuilderScript> {
    
        @Getter @Setter
        private String lastName;                                (1)
        @Getter @Setter
        private String firstName;                               (1)
        @Getter @Setter
        private String phoneNumber;                             (1)
        @Getter @Setter
        private String emailAddress;                            (1)
    
        @Setter
        private List<PetData> petData = Lists.newArrayList();   (1)
    
        @Data
        static class PetData {
            private final String name;
            private final PetSpecies petSpecies;
        }
    
        @Getter                                                 (2)
        private Owner object;
    
        @Override
        protected void execute(final ExecutionContext ec) {
    
            checkParam("lastName", ec, String.class);           (3)
            checkParam("firstName", ec, String.class);
    
            Owner owner = wrap(owners).create(lastName, firstName, phoneNumber);
            wrap(owner).setEmailAddress(emailAddress);
    
            for (PetData petDatum : petData) {
                wrap(owner).newPet(petDatum.name, petDatum.petSpecies);
            }
    
            this.object = owner;
        }
    
        @Inject
        Owners owners;
    }
    1 the inputs to the fixture script.
    2 the output of the fixture script
    3 the checkParam(…​) method is used to ensure that all mandatory properties are provided.

    A more sophisticated script can use the defaultParam(…​) method to provide a default value for all properties where no value was provided. In conjunction with the FakeDataService (which we’ll see shortly), this opens up the idea that a builder can be used to create "some" randomised object, with the test class fixing only the values that are significant to the test scenario.

  • the above class uses the framework’s WrapperFactory domain service, which allows interactions to be made "as if" through the UI. Using this service requires an additional dependency in the pom.xml:

    <dependency>
        <groupId>org.apache.isis.core</groupId>
        <artifactId>isis-core-wrapper</artifactId>
    </dependency>

    This gives access to the framework’s WrapperFactory domain service.

Second, the "know-what" responsibility:

  • create the Owner_enum enum. The "what"s are the enum instances, each delegating the actual creation to the OwnerBuilderScript:

    @AllArgsConstructor
    public enum Owner_enum
            implements PersonaWithBuilderScript<Owner, OwnerBuilderScript> {
    
        JOHN_SMITH("John", "Smith", null, new PetData[]{
                new PetData("Rover", PetSpecies.Dog)
        }),
        MARY_JONES("Mary","Jones", "+353 1 555 1234", new PetData[] {
                new PetData("Tiddles", PetSpecies.Cat),
                new PetData("Harry", PetSpecies.Budgerigar)
        }),
        FRED_HUGHES("Fred","Hughes", "07777 987654", new PetData[] {
                new PetData("Jemima", PetSpecies.Hamster)
        });
    
        private final String firstName;
        private final String lastName;
        private final String phoneNumber;
        private final PetData[] petData;
    
        @Override
        public OwnerBuilderScript builder() {
            return new OwnerBuilderScript()
                    .setFirstName(firstName)
                    .setLastName(lastName)
                    .setPhoneNumber(phoneNumber)
                    .setPetData(Arrays.asList(petData));
        }
    }
  • refactor RecreateOwnersAndPets to use the enum:

    public class RecreateOwnersAndPets extends FixtureScript {
    
        public RecreateOwnersAndPets() {
            super(null, null, Discoverability.DISCOVERABLE);
        }
    
        @Override
        protected void execute(final ExecutionContext ec) {
    
            isisJdoSupport.deleteAll(Pet.class);
            isisJdoSupport.deleteAll(Owner.class);
    
            ec.executeChild(this, new PersonaEnumPersistAll<>(Owner_enum.class));
        }
    
        @Inject
        IsisJdoSupport isisJdoSupport;
    }

Before we get to our integration tests there is one further refinement we can make. We will want to easily "look up" existing objects, so we make the Owner_enum implement a further interface.

  • first, extend Owners domain service to perform an exact lookup:

    @Programmatic
    public Owner findByLastNameAndFirstName(
            final String lastName,
            final String firstName) {
        TypesafeQuery<Owner> q = isisJdoSupport.newTypesafeQuery(Owner.class);
        final QOwner cand = QOwner.candidate();
        q = q.filter(
                cand.lastName.eq(q.stringParameter("lastName")).and(
                cand.firstName.eq(q.stringParameter("firstName"))
                )
        );
        return q.setParameter("lastName", lastName)
                .setParameter("firstName", firstName)
                .executeUnique();
    }
  • now let’s extend Owner_enum to also implement PersonaWithFinder:

    public enum Owner_enum
            implements PersonaWithBuilderScript<Owner, OwnerBuilderScript>,
                       PersonaWithFinder<Owner> {
        ...
        @Override
        public Owner findUsing(final ServiceRegistry2 serviceRegistry) {
            return serviceRegistry.lookupService(Owners.class)
                    .findByLastNameAndFirstName(lastName, firstName);
        }
        ...
    }

Writing Integration Tests

Now we have a refactored our fixture scripts, let’s use them in an integration test, to check that bookVisit works correctly.

Integration tests are not written using Selenium or similar, so avoid the fragility and maintenance effort that such tests often entail. Instead, the framework provides an implementation of the WrapperFactory domain service which simulates the user interface in a type-safe way. Our unit test code is only allowed to invoke the methods of the domain objects that are visible and modifiable.

Solution

git checkout tags/280-writing-integration-tests
mvn clean package jetty:run

If running integration tests from the IDE, make sure that the DataNucleus enhancer has run first. For example, with IntelliJ this is just a matter of running mvn datanucleus:enhance -o first from the command line.

Exercise

  • let’s further refactor RecreateOwnersAndPets, taking account of the fact that fixture scripts implement the composite pattern:

    public class RecreateOwnersAndPets extends FixtureScript {
    
        public RecreateOwnersAndPets() {
            super(null, null, Discoverability.DISCOVERABLE);
        }
    
        @Override
        protected void execute(final ExecutionContext ec) {
            ec.executeChild(this, new DeleteAllOwnersAndPets());
            ec.executeChild(this, new PersonaEnumPersistAll<>(Owner_enum.class));
        }
    }
  • where DeleteAllOwnersAndPets in turn is:

    public class DeleteAllOwnersAndPets extends TeardownFixtureAbstract2 {
        @Override
        protected void execute(final ExecutionContext ec) {
            deleteFrom(Pet.class);
            deleteFrom(Owner.class);
        }
    }
  • let’s also introduce a new DeleteAllVisits fixture:

    public class DeleteAllVisits extends TeardownFixtureAbstract2 {
        @Override
        protected void execute(final ExecutionContext ec) {
            deleteFrom(Visit.class);
        }
    }
  • our integration test, Pet_bookVisit_IntegTest, can now use these fixtures:

    public class Pet_bookVisit_IntegTest extends IntegrationTestAbstract3 {
    
        public Pet_bookVisit_IntegTest() {
            super(new PetClinicModule());
        }
    
        @Before
        public void setUp() {
            runFixtureScript(
                    new DeleteAllVisits(),
                    new DeleteAllOwnersAndPets()
            );
        }
    }
  • Normally it would be sufficient to bootstrap the integration tests using just the module (PetClinicModule in this case). However, since we have (for simplicity) written the integration test in the webapp module, we need to adjust the bootstrapping to disable a domain service (for i18n support) that is on the classpath:

    public class Pet_bookVisit_IntegTest extends IntegrationTestAbstract3 {
    
        public Pet_bookVisit_IntegTest() {
            super(new PetClinicModule()
                    // disable the TranslationServicePo domain service
                    .withAdditionalServices(DeploymentCategoryProviderForTesting.class)
                    .withConfigurationProperty(TranslationServicePo.KEY_PO_MODE, "write")
            );
        }
    
        public static class DeploymentCategoryProviderForTesting
                implements DeploymentCategoryProvider {
            @Getter
            DeploymentCategory deploymentCategory = DeploymentCategory.PROTOTYPING;
        }
    
        ...
    }
  • okay, now let’s write the happy case:

    @Test
    public void happy_case() {
    
        // given
        runFixtureScript(Owner_enum.FRED_HUGHES.builder());
    
        Owner owner = Owner_enum.FRED_HUGHES.findUsing(serviceRegistry);
        Pet pet = owner.getPets().first();
        Pet_bookVisit mixin = factoryService.mixin(Pet_bookVisit.class, pet);
    
        // when
        LocalDateTime default0Act = mixin.default0Act();
        String reason = "off her food";
        Visit visit = wrap(mixin).act(default0Act, reason);
    
        // then
        assertThat(visit.getPet()).isEqualTo(pet);
        assertThat(visit.getVisitAt()).isEqualTo(default0Act);
        assertThat(visit.getReason()).isEqualTo(reason);
    }
  • and let’s also write an error scenario which checks that a reason has been provided:

    @Test
    public void reason_is_required() {
    
        // given
        runFixtureScript(Owner_enum.FRED_HUGHES.builder());
    
        Owner owner = Owner_enum.FRED_HUGHES.findUsing(serviceRegistry);
        Pet pet = owner.getPets().first();
        Pet_bookVisit mixin = factoryService.mixin(Pet_bookVisit.class, pet);
    
        // expect
        expectedExceptions.expect(InvalidException.class);
        expectedExceptions.expectMessage("Mandatory");
    
        // when
        LocalDateTime default0Act = mixin.default0Act();
        String reason = null;
        wrap(mixin).act(default0Act, reason);
    }

Factor out abstract integration test

In the next main section we’ll be looking at extending the scope of the app, but before that we should invest further in our integration testing infrastructure.

Solution

git checkout tags/290-factor-out-abstract-integration-test
mvn clean package jetty:run

Exercise

  • Factor out an abstract class for integration tests:

    public abstract class PetClinicModuleIntegTestAbstract extends IntegrationTestAbstract3 {
    
        public PetClinicModuleIntegTestAbstract() {
            super(new PetClinicModule()
                    // disable the TranslationServicePo domain service
                    .withAdditionalServices(DeploymentCategoryProviderForTesting.class)
                    .withConfigurationProperty(TranslationServicePo.KEY_PO_MODE, "write")
            );
        }
    
        public static class DeploymentCategoryProviderForTesting implements DeploymentCategoryProvider {
            @Getter
            DeploymentCategory deploymentCategory = DeploymentCategory.PROTOTYPING;
        }
    }
  • Update our existing integration test to use this new adapter:

    public class Pet_bookVisit_IntegTest extends PetClinicModuleIntegTestAbstract {
    
        @Before
        public void setUp() { ... }
        @Test
        public void happy_case() { ... }
        @Test
        public void reason_is_required() { ... }
    
    }

Move teardowns to modules

When running a suite of integration tests we need to reset the database to a known state, typically deleting all data (or at least, all non-reference data). Since modules are "containers" of entities (among other things), the framework allows the module to handle this responsibility.

Solution

git checkout tags/300-move-teardowns-to-modules
mvn clean package jetty:run

Exercise

  • Update the PetClinicModule, adding in getRefDataSetupFixture() and getTeardownFixture():

    public class PetClinicModule extends ModuleAbstract {
    
        @Override
        public FixtureScript getRefDataSetupFixture() {
            // nothing currently
            return null;
        }
    
        @Override public FixtureScript getTeardownFixture() {
            return new FixtureScript() {
                @Override
                protected void execute(final ExecutionContext ec) {
                    ec.executeChild(this, new DeleteAllVisits());
                    ec.executeChild(this, new DeleteAllOwnersAndPets());
                }
            };
        }
    }
  • Update Pet_bookVisit_IntegTest, removing the setpUp() method (which deletes all data from the tables)

Fake Data Service

When exercising some functionality, we need to provide valid arguments for the various actions being invoked. Sometimes the values of thosse arguments are significant (eg can’t book a visit for a date in the past), but sometimes they just need to be a value (eg the reason for a visit).

We should be able to understand the behaviour of an application through its tests. To help the reader, it would be good to distinguish between the significant values and the "any old value".

The Incode Platform's Fake Data library provides us with a FakeDataService domain service that helps generate such fake or random data for our tests. Let’s integrate it.

Solution

git checkout tags/310-fake-data-service
mvn clean package jetty:run

Exercise

  • update the pom.xml to reference the Incode Platform’s fake data module.

    Add a property:

    <incode-platform.version>1.16.2</incode-platform.version>

    and add a dependency:

    <dependency>
        <groupId>org.isisaddons.module.fakedata</groupId>
        <artifactId>isis-module-fakedata-dom</artifactId>
        <version>${incode-platform.version}</version>
    </dependency>
  • Extend PetClinicModule to depend upon the FakeDataModule:

    public class PetClinicModule extends ModuleAbstract {
    
        @Override
        public Set<Module> getDependencies() {
            return Sets.newHashSet(new FakeDataModule());
        }
        ...
    }

Extend the Fixture script to set up visits

Some of the functionality we want to test will require visits, but so far our fixture scripts only allow us to set up Owners and their Pets. Let’s extend the fixture scripts so we can declaratively have a number of Visits for each of the Pets also.

Solution

git checkout tags/320-extend-the-fixture-script-to-set-up-visits
mvn clean package jetty:run

Exercise

  • in OwnerBuilderScript

    • inject two new domain services. We’ll need these to compute the date of the Visits.

      @Inject
      FakeDataService fakeDataService;
      
      @Inject
      ClockService clockService;
    • add some helper methods:

      private String someReason() {
          return fakeDataService.lorem().paragraph(fakeDataService.ints().between(1, 3));
      }
      
      private LocalDateTime someRandomTimeInPast() {
          return clockService.now()
                  .toDateTimeAtStartOfDay().minus(fakeDataService.jodaPeriods().daysBetween(5, 365))
                  .plusHours(fakeDataService.ints().between(9, 17))
                  .plusMinutes(5 * fakeDataService.ints().between(0, 12))
                  .toLocalDateTime();
      }
      
      private void setTimeTo(final ExecutionContext ec, final LocalDateTime ldt) {
          ec.executeChild(this, new TickingClockFixture().setDate(ldt.toString("yyyyMMddhhmm")));
      }

      Note the use of the framework-provided TickingClockFixture that lets the time reported by ClockService be changed.

    • extend PetData to specify the number of visits to setup:

      @Data
      static class PetData {
          private final String name;
          private final PetSpecies petSpecies;
          private final int numberOfVisits;
      }
    • extend the execute(…​) method to set up the required number of visits (using the previously added helper methods):

      LocalDateTime now = clockService.nowAsLocalDateTime();
      try {
          for (PetData petDatum : petData) {
              Pet pet = wrap(owner).newPet(petDatum.name, petDatum.petSpecies);
              for (int i = 0; i < petDatum.numberOfVisits; i++) {
                  LocalDateTime someTimeInPast = someRandomTimeInPast();
                  String someReason = someReason();
                  setTimeTo(ec, someTimeInPast);
                  wrap(mixin(Pet_bookVisit.class, pet)).act(someTimeInPast.plusDays(3), someReason);
              }
          }
      } finally {
          setTimeTo(ec, now);
      }
  • extend Owner_enum persona to use all new infrastructure:

    JOHN_SMITH("John", "Smith", null, new PetData[]{
            new PetData("Rover", PetSpecies.Dog, 3)
    }),
    MARY_JONES("Mary","Jones", "+353 1 555 1234", new PetData[] {
            new PetData("Tiddles", PetSpecies.Cat, 1),
            new PetData("Harry", PetSpecies.Budgerigar, 2)
    }),
    FRED_HUGHES("Fred","Hughes", "07777 987654", new PetData[] {
            new PetData("Jemima", PetSpecies.Hamster, 0)
    });

    The difference is simply the last argument in the PetData constructor.