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:
-
use fixture scripts to set up the app’s initial state
-
use the WrapperFactory to simulate the UI.
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:
@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:
@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:
@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:
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:
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
|
||
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:
@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:
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 |
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
:
@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
:
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 ofw()
. -
unwrap()
- to unwrap. -
mixin()
- to instantiate a mixin around a domain object. Also has an alias ofm()
. -
wrapMixin()
- which naturally enoughwrap()
s the result of amixin()
. This has an alias ofwm()
.
This class also provides a number of injected domain services:
-
to query and persist objects through a generic repository
-
for instantiating domain objects and mixins
-
to acces the current user
-
to access domain services
-
to access the metamodel.
-
to simulate interactions through the UI. This is discussed further below.
-
for more control over transactions
The class also defines (as a nested static class) the InteractionSupport
as a domain service.
If @Import
ed into the integration test’s "app manifest", this ensures that any Command
s 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:
-
the integration test calls the
WrapperFactory
to obtain a wrapper for the domain object under test. This is usually done in the test’ssetUp()
method. -
the test calls the methods on the wrapper rather than the domain object itself
-
the wrapper performs a reverse lookup from the method invoked (a regular
java.lang.reflect.Method
instance) into the Apache Isis metamodel -
(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
-
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
.

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:
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
.
@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:
@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.
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
tofalse
inapplication.yml
-
introduce an error, for example by renaming
default0UpdateName
todefault0UpdateFoo
.
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 three separate executions for unit tests, integ tests and for BDD specs. This relies on a naming convention:
Classes named Not following this convention (intermixing unit tests with integ tests) may cause the latter 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 ![]() |