In a monolith you query one database. In a microservices system with database-per-service, each service is the sole owner of its data — User Service owns the users table, Order Service owns the orders table, and they never share a database connection. This eliminates the temptation to write JOIN queries across service boundaries, but it also means every test that used to be a single SQL SELECT now requires coordinating API calls across multiple services, multiple databases, and multiple test setups.
Why database-per-service changes test design
The pattern exists for good reasons — teams can evolve schemas independently, choose the right database for each service (Postgres for orders, Redis for sessions, Elasticsearch for product search), and scale databases independently. But for QA, it means:
- You can't verify a business outcome with a single database query
- You can't seed test data with INSERT statements across multiple service tables
- You can't verify data consistency with a cross-service JOIN
- Each service's database needs its own setup, teardown, and migration run
Multi-database test setup with Testcontainers
The key is to bring up an independent container for each service and wire each one to its owning service alone. Here is how that looks with three services that each use a different database technology:
@Testcontainers
class MultiServiceDataTest {
static Network network = Network.newNetwork();
@Container
static PostgreSQLContainer<?> userDb = new PostgreSQLContainer<>("postgres:15")
.withNetwork(network)
.withNetworkAliases("user-db")
.withDatabaseName("users");
@Container
static PostgreSQLContainer<?> orderDb = new PostgreSQLContainer<>("postgres:15")
.withNetwork(network)
.withNetworkAliases("order-db")
.withDatabaseName("orders");
@Container
static MongoDBContainer productDb = new MongoDBContainer("mongo:7.0")
.withNetwork(network)
.withNetworkAliases("product-db");
// Each service gets @DynamicPropertySource wiring its own DB URL
@DynamicPropertySource
static void configureDataSources(DynamicPropertyRegistry registry) {
// User Service configuration
registry.add("user-service.datasource.url", userDb::getJdbcUrl);
// Order Service configuration
registry.add("order-service.datasource.url", orderDb::getJdbcUrl);
// Product Service configuration
registry.add("product-service.mongo.uri", productDb::getReplicaSetUrl);
}
}Notice that each service uses a different database technology — Postgres, Postgres, and MongoDB. This is the benefit of database-per-service: teams are free to pick the right tool for each problem. The test infrastructure must accommodate that heterogeneity rather than force a single database engine.
The API boundary rule in tests
The most important rule: verify state through the service's own API, never through direct database access. Here is why that distinction matters:
// ❌ BAD — direct database query bypasses service logic
String sql = "SELECT o.id, u.email FROM orders o JOIN user_db.users u ON o.user_id = u.id";
ResultSet rs = orderDb.getConnection().createStatement().executeQuery(sql);
// This is wrong for three reasons:
// 1. Crosses the database ownership boundary (queries user_db from order service's test)
// 2. Bypasses User Service's business logic and caching
// 3. Breaks the moment either service changes its schema// ✅ GOOD — verify state through each service's API
Order order = orderServiceClient.getOrder(orderId);
User user = userServiceClient.getUser(order.getUserId());
assertThat(order.getStatus()).isEqualTo("CONFIRMED");
assertThat(user.getEmail()).isEqualTo("alice@test.com");API-level assertions are resilient to schema changes. If Order Service migrates from a user_email column to a foreign key in a users cache, the API response stays the same. The test does not need to change. The same migration would silently break a test that ran a direct SQL query against the old column name.
Seeding test data correctly
In a monolith you would INSERT test data in a @BeforeEach. In microservices, each service owns its data — INSERTs cross that boundary. The correct approach is to create test data through each service's own API:
@BeforeEach
void seedTestData() {
// Create user via User Service API (respects User Service's validation logic)
testUser = userServiceClient.createUser(
new CreateUserRequest("alice@test.com", "Alice", "hashed-pw"));
// Create product via Product Service API
testProduct = productServiceClient.createProduct(
new CreateProductRequest("Laptop Pro", 999.99, 10));
// Order Service creates its own reference data if needed
// — never INSERT directly into another service's database
}This approach is slower than direct DB inserts and depends on those services being healthy during setup. The payoff is tests that mirror real system behaviour and remain resilient when schemas change. An INSERT bypasses the service's validation, event publishing, and any side-effect logic — it puts the system into a state that can never legitimately occur in production.
Reporting and read models
One common challenge: a management dashboard needs to show "orders with user names" — data that spans two services. The standard solution is a read model populated by events (the CQRS pattern). Test the read model through its own API and use Awaitility to handle the eventual consistency:
// Read model: populated by listening to events from both services
// Test it through the read model's own API, not the source databases
@Test
void readModelShouldReflectOrderAndUserData() {
// Place an order (triggers order.placed event)
orderServiceClient.placeOrder(new CreateOrderRequest(testUser.getId(), testProduct.getId()));
// Wait for read model to update
await().atMost(10, SECONDS).untilAsserted(() -> {
OrderSummary summary = reportingServiceClient.getOrderSummary(orderId);
assertThat(summary.getUserEmail()).isEqualTo("alice@test.com");
assertThat(summary.getProductName()).isEqualTo("Laptop Pro");
assertThat(summary.getStatus()).isEqualTo("PLACED");
});
}- – Owns user DB (Postgres)
- – Only source of user data
- – Verify via /users API
- – Owns order DB (Postgres)
- – References users by ID only
- – Verify via /orders API
- – Owns product DB (MongoDB)
- – Different DB technology
- – Verify via /products API
- Read model from events –
- Cross-service aggregation –
- Eventual consistency –
⚠️ Common mistakes
- Querying another service's database directly in tests for "convenience". Every direct DB query across a service boundary is technical debt that breaks when the schema changes. The convenience of one SELECT statement costs you maintenance every time either service evolves its data model. Use the API — it takes three more lines and earns you resilience against future changes.
- Creating test data with direct INSERT statements instead of service APIs. INSERT statements bypass the service's validation, event publishing, and side-effect logic. An order created by INSERT doesn't trigger an inventory reservation event. An order created via the API does. Tests that use INSERTs for setup are testing a state the system can never legitimately be in.
- Starting all service databases before all service tests, regardless of which services the test actually uses. Starting three database containers for a test that only exercises User Service wastes 6–8 seconds of container startup time per test class. Only start the containers that the test actually requires; use
@Containeron instance fields (not static) if isolation is more important than speed.
🎯 Practice task
- Set up a
MultiServiceDataTestclass with twoPostgreSQLContainerinstances on the same TestcontainersNetwork. Configure each to serve a different database. Verify both are reachable by running a simple health check query against each. - Write a
@BeforeEachthat seeds test data by calling the User Service API and the Product Service API (not by INSERTing rows). Confirm the service responses contain the IDs you'll use in the test assertions. - Write a cross-service test that: (a) places an order via Order Service API, (b) queries User Service to verify the user still exists, (c) queries Order Service to verify the order was created with the correct user ID. No direct DB queries.
- Deliberately break the encapsulation: write a direct JDBC query against the Order Service database from within a User Service test. Run both services' tests together and observe what happens when you change the orders table schema. Document the failure.
- Research the CQRS (Command Query Responsibility Segregation) pattern. Write a test for a simple read model that aggregates data from two services via events. Use Awaitility to wait for the read model to update after each event.
The next lesson covers the saga pattern — the technique microservices use to coordinate multi-step workflows across service boundaries without distributed transactions, and how to test both the happy path and every compensation scenario.