MongoDB and NoSQL Testing with Testcontainers
MongoDB’s document model introduces testing challenges that relational database tests do not have: schema flexibility (different documents can have different fields), aggregation pipelines, geospatial queries, and text search. Testing these against an embedded Flapdoodle MongoDB works for basic CRUD but breaks down for aggregation pipelines, custom index types, and MongoDB-version-specific behavior. This article covers MongoDB testing with a real MongoDB container.
What You’ll Learn
MongoDBContainersetup and@ServiceConnection@DataMongoTestslice testing for repositories- Testing aggregation pipelines
- Testing compound indexes and text indexes
- Querying documents with
MongoTemplate - Testing document validation rules
Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mongodb</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
Main dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
The Domain Model
For MongoDB testing, extend the order model with a document-oriented design:
@Document(collection = "orders")
public class OrderDocument {
@Id
private String id;
private String customerId;
private OrderStatus status;
private BigDecimal totalAmount;
private List<OrderItem> items = new ArrayList<>();
private Address shippingAddress;
@Indexed
private LocalDateTime createdAt;
@Document
public static class OrderItem {
private String productId;
private String productName;
private int quantity;
private BigDecimal unitPrice;
}
@Document
public static class Address {
private String street;
private String city;
private String country;
private String postalCode;
}
}
public interface OrderDocumentRepository extends MongoRepository<OrderDocument, String> {
List<OrderDocument> findByCustomerId(String customerId);
List<OrderDocument> findByStatus(OrderStatus status);
List<OrderDocument> findByItemsProductId(String productId);
@Query("{ 'totalAmount': { $gt: ?0 } }")
List<OrderDocument> findOrdersAboveAmount(BigDecimal amount);
}
Basic MongoDBContainer Setup
@SpringBootTest
@Testcontainers
class OrderDocumentRepositoryTest {
@Container
@ServiceConnection
static MongoDBContainer mongodb =
new MongoDBContainer("mongo:7.0");
@Autowired
private OrderDocumentRepository orderRepository;
@BeforeEach
void cleanup() {
orderRepository.deleteAll();
}
@Test
void shouldSaveAndFindDocument() {
OrderDocument order = new OrderDocument();
order.setCustomerId("customer-1");
order.setStatus(OrderStatus.PENDING);
order.setTotalAmount(BigDecimal.valueOf(99.99));
OrderDocument saved = orderRepository.save(order);
assertThat(saved.getId()).isNotNull();
assertThat(orderRepository.findById(saved.getId())).isPresent();
}
@Test
void shouldFindByNestedArrayField() {
OrderDocument order = new OrderDocument();
order.setCustomerId("customer-1");
order.setStatus(OrderStatus.PENDING);
order.setTotalAmount(BigDecimal.valueOf(99.99));
OrderDocument.OrderItem item = new OrderDocument.OrderItem();
item.setProductId("product-001");
item.setProductName("Widget A");
item.setQuantity(2);
item.setUnitPrice(BigDecimal.valueOf(49.99));
order.setItems(List.of(item));
orderRepository.save(order);
List<OrderDocument> orders = orderRepository.findByItemsProductId("product-001");
assertThat(orders).hasSize(1);
assertThat(orders.get(0).getItems().get(0).getProductName()).isEqualTo("Widget A");
}
}
@DataMongoTest Slice Testing
@DataMongoTest loads only the MongoDB-related beans — repositories, MongoTemplate, and auto-configurations. It is faster than @SpringBootTest for repository-focused tests.
@DataMongoTest
@Testcontainers
class OrderDocumentRepositorySliceTest {
@Container
@ServiceConnection
static MongoDBContainer mongodb =
new MongoDBContainer("mongo:7.0");
@Autowired
private OrderDocumentRepository orderRepository;
@Autowired
private MongoTemplate mongoTemplate;
@Test
void shouldQueryWithMongoTemplate() {
OrderDocument order = createOrder("customer-1", OrderStatus.PENDING, 99.99);
orderRepository.save(order);
Query query = new Query(
Criteria.where("customerId").is("customer-1")
.and("status").is(OrderStatus.PENDING)
);
List<OrderDocument> found = mongoTemplate.find(query, OrderDocument.class);
assertThat(found).hasSize(1);
}
}
Testing Aggregation Pipelines
Aggregation pipelines are MongoDB’s most powerful feature and the one most likely to break in an embedded test environment. Real MongoDB containers handle complex aggregations correctly.
@Component
public class OrderAnalyticsRepository {
private final MongoTemplate mongoTemplate;
public OrderAnalyticsRepository(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
public List<CustomerOrderSummary> getCustomerOrderSummaries() {
Aggregation aggregation = Aggregation.newAggregation(
Aggregation.match(Criteria.where("status").ne(OrderStatus.CANCELLED)),
Aggregation.group("customerId")
.count().as("totalOrders")
.sum("totalAmount").as("totalSpent")
.avg("totalAmount").as("averageOrderValue"),
Aggregation.project("totalOrders", "totalSpent", "averageOrderValue")
.and("_id").as("customerId"),
Aggregation.sort(Sort.by(Sort.Direction.DESC, "totalSpent")),
Aggregation.limit(10)
);
AggregationResults<CustomerOrderSummary> results = mongoTemplate.aggregate(
aggregation,
"orders",
CustomerOrderSummary.class
);
return results.getMappedResults();
}
public Map<String, Long> getOrderCountByStatus() {
Aggregation aggregation = Aggregation.newAggregation(
Aggregation.group("status").count().as("count"),
Aggregation.project("count").and("_id").as("status")
);
AggregationResults<StatusCount> results = mongoTemplate.aggregate(
aggregation,
"orders",
StatusCount.class
);
return results.getMappedResults().stream()
.collect(Collectors.toMap(
StatusCount::getStatus,
StatusCount::getCount
));
}
}
Testing the aggregations:
@DataMongoTest
@Testcontainers
@Import(OrderAnalyticsRepository.class)
class OrderAnalyticsRepositoryTest {
@Container
@ServiceConnection
static MongoDBContainer mongodb = new MongoDBContainer("mongo:7.0");
@Autowired
private OrderDocumentRepository orderRepository;
@Autowired
private OrderAnalyticsRepository analyticsRepository;
@BeforeEach
void setup() {
orderRepository.deleteAll();
// Customer 1: 3 orders totaling $324.98
orderRepository.save(createOrder("customer-1", OrderStatus.CONFIRMED, 99.99));
orderRepository.save(createOrder("customer-1", OrderStatus.DELIVERED, 124.99));
orderRepository.save(createOrder("customer-1", OrderStatus.SHIPPED, 100.00));
// Customer 2: 2 orders totaling $149.98
orderRepository.save(createOrder("customer-2", OrderStatus.PENDING, 74.99));
orderRepository.save(createOrder("customer-2", OrderStatus.CONFIRMED, 74.99));
// Cancelled order — should be excluded
orderRepository.save(createOrder("customer-1", OrderStatus.CANCELLED, 50.00));
}
@Test
void shouldCalculateCustomerOrderSummaries() {
List<CustomerOrderSummary> summaries = analyticsRepository.getCustomerOrderSummaries();
assertThat(summaries).hasSize(2);
CustomerOrderSummary topCustomer = summaries.get(0);
assertThat(topCustomer.getCustomerId()).isEqualTo("customer-1");
assertThat(topCustomer.getTotalOrders()).isEqualTo(3);
assertThat(topCustomer.getTotalSpent())
.isEqualByComparingTo(BigDecimal.valueOf(324.98));
}
@Test
void shouldCountOrdersByStatus() {
Map<String, Long> countByStatus = analyticsRepository.getOrderCountByStatus();
assertThat(countByStatus).containsEntry("CONFIRMED", 2L);
assertThat(countByStatus).containsEntry("PENDING", 1L);
assertThat(countByStatus).containsEntry("CANCELLED", 1L);
}
private OrderDocument createOrder(String customerId, OrderStatus status, double amount) {
OrderDocument order = new OrderDocument();
order.setCustomerId(customerId);
order.setStatus(status);
order.setTotalAmount(BigDecimal.valueOf(amount));
order.setCreatedAt(LocalDateTime.now());
return order;
}
}
Testing Indexes
Verify that your indexes exist and are used correctly:
@Test
void shouldCreateRequiredIndexes() {
// Ensure the index configuration has been applied
IndexOperations indexOps = mongoTemplate.indexOps(OrderDocument.class);
List<IndexInfo> indexes = indexOps.getIndexInfo();
List<String> indexNames = indexes.stream()
.map(IndexInfo::getName)
.collect(Collectors.toList());
// The _id index is always present
assertThat(indexNames).contains("_id_");
// Our @Indexed createdAt field should have an index
assertThat(indexNames).anyMatch(name -> name.contains("createdAt"));
}
Testing Document Validation
MongoDB supports JSON Schema validation at the collection level. Test that your schema validation rules are enforced:
@BeforeAll
static void createCollectionWithValidation(@Autowired MongoTemplate mongoTemplate) {
if (!mongoTemplate.collectionExists("orders")) {
mongoTemplate.createCollection("orders");
}
Document validationSchema = new Document("$jsonSchema", new Document()
.append("bsonType", "object")
.append("required", List.of("customerId", "status", "totalAmount"))
.append("properties", new Document()
.append("totalAmount", new Document()
.append("bsonType", "decimal")
.append("minimum", 0)
)
)
);
mongoTemplate.getDb().runCommand(new Document("collMod", "orders")
.append("validator", validationSchema)
.append("validationLevel", "strict")
);
}
Common Pitfalls
Using flapdoodle embedded MongoDB for complex queries. The de.flapdoodle.embed.mongo library works for basic CRUD but does not support all aggregation operators and index types available in real MongoDB. If you use complex aggregations, switch to MongoDBContainer.
Not cleaning up between tests. MongoDB documents persist between tests in a shared container. Always call repository.deleteAll() in @BeforeEach.
Case sensitivity in field names. MongoDB field names are case-sensitive. A query on customerId does not match documents with customerid. Verify field name casing in Spring Data MongoDB @Field annotations matches your documents.
Summary
MongoDBContainer provides a real MongoDB instance for integration tests. Aggregation pipelines, text indexes, JSON schema validation, and geospatial queries all test correctly against real MongoDB. @DataMongoTest with @ServiceConnection gives you a fast, focused test slice for repository and query testing.
The next article covers database migration testing with Flyway and Liquibase — verifying that your migration scripts apply cleanly against real databases.