Integration Test Support

Apache Isis builds on top of Spring Boot’s integration testing support, in particular using @SpringBootTest. This configures and bootstraps the Apache Isis runtime, usually running against an in-memory database.

On top of that, we usually:

To explain further, let’s walk through of the SimpleApp starter app.

Bootstrapping

We typically put the bootstrapping of Apache Isis into a superclass, one for each Maven module. This allows an individual "slice" of the application to be tested, rather than as a monolith.

So, for the module-simple module, we have:

SimpleModuleIntegTestAbstract.java
@SpringBootTest(
        classes = SimpleModuleIntegTestAbstract.AppManifest.class
)
@TestPropertySource({
        IsisPresets.H2InMemory_withUniqueSchema,
        IsisPresets.DataNucleusAutoCreate,
        IsisPresets.UseLog4j2Test,
})
public abstract class SimpleModuleIntegTestAbstract extends IsisIntegrationTestAbstractWithFixtures {

    @Configuration
    @Import({
        IsisModuleCoreRuntimeServices.class,
        IsisModuleSecurityBypass.class,
        IsisModuleJdoDataNucleus5.class,
        IsisModuleTestingFixturesApplib.class,
        SimpleModule.class                      (1)
    })
    public static class AppManifest {
    }
}
1 references just the SimpleModule

while in the webapp module we have:

ApplicationIntegTestAbstract.java
@SpringBootTest(
        classes = ApplicationIntegTestAbstract.AppManifest.class
)
@TestPropertySource({
        IsisPresets.H2InMemory_withUniqueSchema,
        IsisPresets.DataNucleusAutoCreate,
        IsisPresets.UseLog4j2Test,
})
@ContextConfiguration
public abstract class ApplicationIntegTestAbstract extends IsisIntegrationTestAbstract {

    @Configuration
    @Import({
        IsisModuleCoreRuntimeServices.class,
        IsisModuleJdoDataNucleus5.class,
        IsisModuleSecurityBypass.class,
        IsisModuleTestingFixturesApplib.class,
        ApplicationModule.class                 (1)
    })
    public static class AppManifest {
    }
}
1 references the top-level ApplicationModule

You can see that these are very similar, what’s different is the module referenced.

They both also force an in-memory database, with JDO/DataNucleus ORM configured to autocreate the database schema.

Faster bootstrapping

By default integration tests are run in "production" deployment mode. With the isis.core.meta-model.introspector.mode= set to its default value, this results in full introspection of the Apache Isis metamodel.

While this does have the benefit that the metamodel will be validated, the downside is that the test takes longer to bootstrap.

To bootstrap lazily, set the property to 'lazy'. This can be done by adding:

@TestPropertySource(IsisPresets.IntrospectLazily)

Typical Usage

Let’s now drill down and look at SimpleObject_IntegTest, which (not surprisingly) is the integration test that exercises the SimpleObject class:

SimpleObject_IntegTest.java
@Transactional                                                                  (1)
public class SimpleObject_IntegTest extends SimpleModuleIntegTestAbstract {     (2)

    SimpleObject simpleObject;

    @BeforeEach                                                                 (3)
    public void setUp() {
        // given
        simpleObject = fixtureScripts.runPersona(SimpleObject_persona.FOO);     (4)
    }

    //...
}
1 The Spring @Transactional annotation means that any changes made in the database will be rolled back automatically. There’s further discussion below.
2 inherits from the module-level abstract class (which in turn will be annotated with @SpringBootTest, see bootstrapping earlier).
3 uses JUnit 5
4 uses a fixture script to setup the database. The FixtureScripts domain service is inherited from the superclass.

Testing a property

For example, let’s test the SimpleObject#name property:

SimpleObject.java
import lombok.Getter;
import lombok.Setter;

@DomainObject()
public class SimpleObject
    ...
    @Getter @Setter
    @Name private String name;
    ...
}

Properties are visible but unmodifiable by default We therefore have two tests for each of these facts.

Non-editable properties is change from Isis v1.x. Use the isis.applib.annotation.domain-object.editing configuration property to pverride.

By convention, we group tests into nested static classes, so the corresponding integration test is:

SimpleObject_IntegTest.java
public class SimpleObject_IntegTest extends SimpleModuleIntegTestAbstract {
    ...
    public static class name extends SimpleObject_IntegTest {   (1)

        @Test
        public void accessible() {
            // when
            final String name = wrap(simpleObject).getName();   (2)

            // then
            assertThat(name).isEqualTo(simpleObject.getName()); (3)
        }

        @Test
        public void not_editable() {

            // expect
            assertThrows(DisabledException.class, ()->{         (4)

                // when
                wrap(simpleObject).setName("new name");
            });
        }
    }
}
1 inherit from SimpleObject_IntegTest
JUnit 5’s @Nested annotation doesn’t work here.
2 we use the WrapperFactory to access the domain object.

This will check that property (for access) is visible to the current user.

3 we use AssertJ for assertions.
4 attempting to set the name on the property (through the wrapper) is disallowed. We detect this by catching a DisabledException (using JUnit 5’s assertThrows()).

Testing an action

The way that the simple app allows the name to be updated is through an updateName action:

SimpleObject.java
@DomainObject()
public class SimpleObject
    ...
    public static class UpdateNameActionDomainEvent extends SimpleObject.ActionDomainEvent {}
    @Action(...
            domainEvent = UpdateNameActionDomainEvent.class)                (1)
    public SimpleObject updateName(
            @Name final String name) {                                      (2)
        setName(name);                                                      (3)
        return this;
    }
    public String default0UpdateName() {                                    (4)
        return getName();
    }
    ...
}
1 an event will be emitted whenever the action is interacted with
2 (the @Name annotation is discussed later)
3 the business functionality to be tested.
4 (we normally test default methods using unit tests).

The corresponding integration test is:

SimpleObject_IntegTest.java
public class SimpleObject_IntegTest extends SimpleModuleIntegTestAbstract {
    ...
    public static class updateName extends SimpleObject_IntegTest {

        @DomainService                                                      (1)
        public static class UpdateNameListener {

            @Getter
            List<SimpleObject.UpdateNameActionDomainEvent> events = new ArrayList<>();

            @EventListener(SimpleObject.UpdateNameActionDomainEvent.class)
            public void on(SimpleObject.UpdateNameActionDomainEvent ev) {
                events.add(ev);
            }
        }

        @Inject
        UpdateNameListener updateNameListener;

        @Test
        public void can_be_updated_directly() {

            // given
            updateNameListener.getEvents().clear();

            // when
            wrap(simpleObject).updateName("new name");                      (2)
            transactionService.flushTransaction();

            // then
            assertThat(wrap(simpleObject).getName()).isEqualTo("new name"); (3)
            assertThat(updateNameListener.getEvents()).hasSize(5);          (4)
        }
    }
1 defines a domain service to detect the domain events emitted when interacting with the action (through the wrapper).

This domain service is not part of the production code base, but is detected automatically thanks to @ComponentScan on the SimpleModule.

2 interact with the action through the wrapper
3 verify the object was updated correctly
4 verify that the domain events were emitted.

Since this was a successful interaction, there will have been 5 events, one for each of the event phases (hide, disable, validate, executing, executed)

Testing a type meta-annotation

As was pointed out earlier, the name parameter to updateName() has the @Name meta-annotation. This meta-annotation also defines some business rules, through @Property and @Parameter:

Name.java
@Property(mustSatisfy = Name.NoExclamationMarks.class, maxLength = Name.MAX_LEN)
@Parameter(mustSatisfy = Name.NoExclamationMarks.class, maxLength = Name.MAX_LEN)
@ParameterLayout(named = "Name")
...
public @interface Name {

    int MAX_LEN = 40;

    class NoExclamationMarks extends AbstractSpecification2<String> {

        @Override
        public TranslatableString satisfiesTranslatableSafely(final String name) {
            return name != null && name.contains("!")
                    ? TranslatableString.tr("Exclamation mark is not allowed")
                            : null;
        }
    }
}

We test this by checking the wrapper throws an InvalidException:

SimpleObject_IntegTest.java
public class SimpleObject_IntegTest extends SimpleModuleIntegTestAbstract {
    ...
    public static class updateName extends SimpleObject_IntegTest {
        ...
        @Test
        public void failsValidation() {

            // expect
            InvalidException cause = assertThrows(InvalidException.class, ()->{

                // when
                wrap(simpleObject).updateName("new name!");
            });

            // then
            assertThat(cause.getMessage(), containsString("Exclamation mark is not allowed."));
        }
    }

Some of the methods being called here are inherited from the superclass, so let’s look at that next.

IsisIntegrationTestAbstract

For convenience the framework provides the IsisIntegrationTestAbstract as a base class for integration tests.

This is the class that provides the wrap(…​) method (as see in the previous section). It also provides a number of other convenience methods:

  • wrap() - as just mentioned. Also has an alias of w().

  • unwrap() - to unwrap.

  • mixin() - to instantiate a mixin around a domain object. Also has an alias of m().

  • wrapMixin() - which naturally enough wrap()s the result of a mixin(). This has an alias of wm().

This class also provides a number of injected domain services:

The class also defines (as a nested static class) the CommandSupport as a domain service. If @Imported into the integration test’s "app manifest", this ensures that any Commands that are raised as the result of interactions through the wrapper factory are setup correctly.

Wrapper Factory

The WrapperFactory service is responsible for wrapping a domain object in a dynamic proxy, of the same type as the object being proxied. The role of this wrapper is to simulate the UI.

It does this by allowing through method invocations that would be allowed if the user were interacting with the domain object through one of the viewers, but throwing an exception if the user attempts to interact with the domain object in a way that would not be possible if using the UI. The WrapperFactory uses ByteBuddy to perform its magic.

The mechanics are as follows:

  1. the integration test calls the WrapperFactory to obtain a wrapper for the domain object under test. This is usually done in the test’s setUp() method.

  2. the test calls the methods on the wrapper rather than the domain object itself

  3. the wrapper performs a reverse lookup from the method invoked (a regular java.lang.reflect.Method instance) into the Apache Isis metamodel

  4. (like a viewer), the wrapper then performs the "see it/use it/do it" checks, checking that the member is visible, that it is enabled and (if there are arguments) that the arguments are valid

  5. if the business rule checks pass, then the underlying member is invoked. Otherwise an exception is thrown.

The type of exception depends upon what sort of check failed. It’s straightforward enough: if the member is invisible then a HiddenException is thrown; if it’s not usable then you’ll get a DisabledException, if the args are not valid then catch an InvalidException.

wrapper factory

Wrapping and Unwrapping

Wrapping a domain object is very straightforward; simply call WrapperFactory#wrap(…​).

For example:

Customer customer = ...;
Customer wrappedCustomer = wrapperFactory.wrap(wrappedCustomer);

Going the other way — getting hold of the underlying (wrapped) domain object — is just as easy; just call WrapperFactory#unwrap(…​).

For example:

Customer wrappedCustomer = ...;
Customer customer = wrapperFactory.unwrap(wrappedCustomer);

If you prefer, you also can get the underlying object from the wrapper itself, by downcasting to WrappingObject and calling __isis_wrapped() method:

Customer wrappedCustomer = ...;
Customer customer = (Customer)((WrappingObject)wrappedCustomer).__isis_wrapped());

We’re not sure that’s any easier (in fact we’re certain it looks rather obscure). Stick with calling unwrap(…​)!

Using the wrapper

As the wrapper is intended to simulate the UI, only those methods that correspond to the "primary" methods of the domain object’s members are allowed to be called. That means:

  • for object properties the test can call the getter or setter method

  • for object collections the test can call the getter to access the contents.

    For this to work properly, the collection should be marked as read-only, generally globally using the isis.applib.annotation.domain-object.editing configuration property.
  • for object actions the test can call the action method itself.

As a convenience, we also allow the test to call any default…​(),choices…​() or autoComplete…​() method. These are often useful for obtaining a valid value to use.

What the test can’t call is any of the remaining supporting methods, such as hide…​(), disable…​() or validate…​(). That’s because their value is implied by the exception being thrown.

The wrapper does also allow the object’s title() method or its toString() , however this is little use for objects whose title is built up using the @Title annotation. Instead, we recommend that your test verifies an object’s title by calling TitleService#titleOf(…​) method.

Firing Domain Events

As well as enforcing business rules, the wrapper has another important feature, namely that it will cause domain events to be fired.

The walk through of SimpleApp above showed how this works.

Manual Teardown

The Spring @Transactional annotation means that any changes made in the database will be rolled back automatically. Normally this is what we want. For more on this topic, see the Spring docs.

Sometimes you may want to commit to the database to verify state, eg using Spring’s @Commit annotation. In these cases, you’ll need to manually tear down the database contents afterwards, in readiness for the next test.

This can be done using the fixture library’s ModuleWithFixtures interface:

ModuleWithFixtures.java
public interface ModuleWithFixtures {
    default FixtureScript getRefDataSetupFixture() {        (1)
        return FixtureScript.NOOP;
    }
    default FixtureScript getTeardownFixture() {            (2)
        return FixtureScript.NOOP;
    }
}
1 Optionally each module can define a FixtureScript which holds immutable "reference data".
2 Optionally each module can define a tear-down FixtureScript, used to remove the contents of all of the operational/transactional entities (but ignoring reference data fixtures).

This should be implemented by module classes, eg as is the case for SimpleModule.

SimpleModule.java
@Configuration
@Import({})
@ComponentScan
public class SimpleModule implements ModuleWithFixtures {

    @Override
    public FixtureScript getTeardownFixture() {
        return new TeardownFixtureAbstract() {
            @Override
            protected void execute(ExecutionContext executionContext) {
                deleteFrom(SimpleObject.class);
            }
        };
    }
    ...
}

The ModuleWithFixturesService aggregates all these setup and teardown fixtures, honouring the module dependencies:

ModuleWithFixtures.java
@Service
public class ModuleWithFixturesService {
    ...
    public FixtureScript getRefDataSetupFixture() { ... }
    public FixtureScript getTeardownFixture() { ... }
    ...
}

Thus, the setup fixture runs the setup for the "inner-most" "leaf-level" modules with no dependencies first, whereas the teardown fixture runs in the opposite order, with the "outer-most" "top-level" modules torn down first.

Validate MetaModel

Metamodel validation checks for a number of semantic errors with the domain model. For example, if a supporting method (such as default0UpdateName() in SimpleObject) is misspelt, then this would be flagged.

Running up the application will flag any metamodel validation issues, as will running an integration test. However, depending upon configuration, the metamodel may only be built lazily, meaning that issues will only be detected while the application is running, rather than at bootstrap.

The DomainModelValidator is a simple utility class that will fullu rebuild the metamodel if required, and then verify that there are no issues.

ValidateDomainModel_IntegTest.java
class ValidateDomainModel_IntegTest
        extends ApplicationIntegTestAbstract {

    @Inject ServiceRegistry serviceRegistry;

    @Test
    void validate() {
        new DomainModelValidator(serviceRegistry).assertValid();
    }
}

To see this in action in the simpleapp starter app:

  • change isis.applib.annotation.action.explicit to false in application.yml

  • introduce an error, for example by renaming default0UpdateName to default0UpdateFoo.

Swagger Exporter

@SpringBootTest( classes = { ApplicationIntegTestAbstract.AppManifest.class, IsisModuleViewerRestfulObjectsJaxrsResteasy4.class } ) class SwaggerExport_IntegTest extends ApplicationIntegTestAbstract {

@Inject ServiceRegistry serviceRegistry;
    @Test
    void export() throws IOException {
        new SwaggerExporter(serviceRegistry)
                .export(SwaggerService.Visibility.PRIVATE, SwaggerService.Format.JSON);
    }
}

Maven Configuration

Apache Isis' integ test support is most easily configured through a dependency on the isis-mavendeps-integtests module:

<dependency>
    <groupId>org.apache.isis.mavendeps</groupId>
    <artifactId>isis-mavendeps-integtests</artifactId>
    <scope>test</scope>                             (1)
    <type>pom</type>
</dependency>
1 Normally test; usual Maven scoping rules apply.

This will set up integration testing support . There is no need to specify the version if you inherit from from the Parent POM.

The Parent POM configures the Maven surefire plugin in two separate executions for unit test and fo integ tests. This relies on a naming convention:

  • unit tests, which must have a suffix Test, but excluding …​

  • integ tests, which must have the suffix IntegTest

Not following this convention is likely to cause integ tests to fail.

If you just want to set up integration testing support, then use:

<dependency>
    <groupId>org.apache.isis.core</groupId>
    <artifactId>isis-core-integtestsupport</artifactId>
    <scope>test</scope>
</dependency>

Hints-n-Tips

Using JDO/DataNucleus

When running integration tests through the IDE, and if using the JDO/DataNucleus ORM, then make sure the module(s) with entities have been enhanced first.

If using IntelliJ, we recommend creating a run Maven configuration that runs datanucleus:enhance, and then pinning the configuration once run as a tab in the "Debug" window for easy access.

pin enhance run configuration