(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.
Exercise
First, the "know-how-to" responsibility:
-
create
OwnerBuilderScript
. This knows "how to" create anOwner
and theirPet
s 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 theFakeDataService
(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 thepom.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 theOwnerBuilderScript
:@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 implementPersonaWithFinder
: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 |
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.
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.
Exercise
-
Update the
PetClinicModule
, adding ingetRefDataSetupFixture()
andgetTeardownFixture()
: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 thesetpUp()
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.
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 theFakeDataModule
: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 Owner
s and their Pet
s.
Let’s extend the fixture scripts so we can declaratively have a number of Visit
s for each of the Pet
s 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
Visit
s.@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 byClockService
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.