Testing with Spring Boot
To test the annotations, and the catch-up and checkpoint machinery behind them, you need a real MongoDB replica set. Provide it as a @ServiceConnection bean so Boot points itself at the container:
@SpringBootTest@Testcontainersclass ProjectionAnnotationTest {
@TestConfiguration static class Containers {
@Bean @ServiceConnection MongoDBContainer mongoDbContainer() { return new MongoDBContainer("mongo:8.0").withReplicaSet(); } }}One trap to watch out for is that MongoDBContainer.getReplicaSetUrl() with no argument always targets the database named test. You cannot append a suffix to the returned URL either, because MongoDB rejects dots in database names, so the name silently stays test and tests collide. When a test needs its own database, ask for it by name with getReplicaSetUrl(String).
Wiring it into a Spring Boot test
Section titled “Wiring it into a Spring Boot test”occurrent-testing-spring-boot wires the same extension into the application context, so a test autowires it rather than constructing it:
<dependency> <groupId>org.occurrent</groupId> <artifactId>occurrent-testing-spring-boot</artifactId> <version>0.34.0</version> <scope>test</scope></dependency>@SpringBootTest@EnableOccurrentTestingclass OrderProjectionTest {
@Autowired @RegisterExtension OccurrentSubscriptionsExtension subscriptions;}@SpringBootTest@EnableOccurrentTestingclass OrderProjectionTest {
@Autowired @RegisterExtension lateinit var subscriptions: OccurrentSubscriptionsExtension}Since 0.33.0, the extension bean clears checkpoints on its own. It applies clearingCheckpoints(..) automatically once the context has exactly one CheckpointStorage bean, so an application with a single checkpoint store needs no further configuration for that half. Your event store and subscription model are otherwise left exactly as the application wires them, so the test still runs against the real store. That is the point, since a subscription is only worth testing against the change streams, checkpoints and catch-up it actually uses.
@EnableOccurrentTesting wires the extension for the module you added as a test dependency, occurrent-testing-junit-jupiter-blocking, occurrent-testing-junit-jupiter-reactor, or both. An application using both stacks at once gets two extension beans, one per stack, autowired by type. Either way, the extension stops every subscription model in the context rather than one, since some applications have more than one that needs stopping, a durable model and a SynchronousSubscriptionModel side by side is the ordinary case on the reactive stack. Outside Spring the same rule applies to stoppedByDefault, pass every model it needs to stop: stoppedByDefault(durableModel, synchronousModel).
clearState = true on @EnableOccurrentTesting does the same for the database flush, applying clearingStateWith(..) automatically once a store integration is available to flush with, occurrent-testing-mongodb plus a MongoTemplate bean today. It is false by default, since flushing is destructive and not every test wants it.
To configure the flush by hand instead, more than one CheckpointStorage bean in the context (which the automatic checkpoint clearing above leaves unresolved), or a narrower sweep than everyCollectionIn, collectionsIn(..), except(..), or droppingTheDatabaseIn(..) below, configure the injected extension in an @Autowired method. The extension bean is prototype scoped, so do not take it as a method parameter, that would configure a different instance than the one the test registers. Configure the field instead, Spring injects fields before methods:
@Autowired@RegisterExtensionOccurrentSubscriptionsExtension subscriptions;
@Autowiredvoid addStateClearing(MongoTemplate mongoTemplate, CheckpointStorage checkpointStorage) { subscriptions.clearingStateWith(OccurrentMongoFlush.everyCollectionIn(mongoTemplate.getDb())) .clearingCheckpoints(checkpointStorage);}@Autowired@RegisterExtensionlateinit var subscriptions: OccurrentSubscriptionsExtension
@Autowiredfun addStateClearing(mongoTemplate: MongoTemplate, checkpointStorage: CheckpointStorage) { subscriptions.clearingStateWith(OccurrentMongoFlush.everyCollectionIn(mongoTemplate.db)) .clearingCheckpoints(checkpointStorage)}Your application registers its subscriptions at startup through its annotations, so a test never registers them itself. Outside Spring, register them once in @BeforeAll. Doing it in @BeforeEach fails on the second test with Subscription <id> is already defined, and would not work anyway, because JUnit runs an extension’s beforeEach before any @BeforeEach method, so a subscription created there is never stopped.
everyCollectionIn deletes the documents rather than dropping anything, which matters for two reasons.
Dropping a collection or a database invalidates a live MongoDB change stream, and the subscriptions you resume afterwards then receive nothing. Stopping the model first does not save you, because it resumes from a position that points into a collection which no longer exists.
The quieter reason is that your event store creates its unique indexes when it is constructed, so dropping removes them and only a new store brings them back. Spring caches the test context, so nothing constructs one again, and your optimistic-concurrency and duplicate-detection tests keep passing for the rest of the run with no index behind them. That failure never announces itself, which makes it the worse of the two.
It names no collections, on purpose. Occurrent writes to more of them than you are likely to remember: the events, a stream position collection, a DCB checkpoint collection, the checkpoint collection below, and a competing consumer lock collection. A list you maintain by hand stops covering one the day you switch a feature on. Use collectionsIn(database, ..) only when the database holds something a test has to keep, and except(..) to spare one collection from the sweep.
For the one case deleting cannot serve, a test asserting that a collection or an index does not exist, there is droppingTheDatabaseIn(database). Nothing else should use it.
The checkpoints have to go too, which is what clearingCheckpoints is for. Resuming a subscription continues from its stored checkpoint, so a subscription left behind by an earlier test picks up whatever that test wrote while it was stopped, and the second test then sees events it never wrote. Clearing the events alone does not prevent this, because the checkpoint is what decides where the resume starts. It works through CheckpointStorage rather than a collection name, so it is right whether your checkpoints live beside the events or in Redis. clearingCheckpointsFor(checkpointStorage, ids...) names checkpoints to clear for subscriptions no model reports yet, for the same reason start("someId") sometimes needs an id the model hasn’t registered.
The in-memory subscription model does not have this problem. Events written while a subscription is stopped are dropped rather than queued, so there is nothing to catch up on when a later test starts it again.
Then keep at least one test with everything running. Deny-by-default means nothing checks two subscriptions reacting to the same event unless you ask it to.
One cost stays whatever the tests do. Every subscription still starts once while the application context boots, and is stopped again before the first test, so on MongoDB you pay for a change stream opened and closed per subscription per context. A JUnit extension runs after the context is refreshed, so nothing in the test can prevent that.
A subscription id that no longer exists throws IllegalArgumentException from resumeSubscription, so renaming one breaks the tests that named it instead of quietly leaving them asserting nothing.
