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

  • MongoDBContainer setup and @ServiceConnection
  • @DataMongoTest slice 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.

Next: Database Migration Testing — Flyway and Liquibase