Testcontainers for Real Database Integration Tests
Run integration tests with real PostgreSQL, MySQL, Redis, and Kafka services using Testcontainers. Learn setup, networking, CI configuration, and cleanup.
Testcontainers lets Java integration tests launch real databases and services in disposable Docker containers, so they can exercise production-specific SQL and network behavior. The guide covers JUnit and Spring Boot setup, mapped ports, startup waits, security, CI configuration, and common failure modes. Use its decision points to choose where container-backed tests add confidence and where mocks or embedded databases keep feedback faster.
Testcontainers for Real Database Integration Tests
Introduction
Integration tests sit between fast unit tests and realistic end-to-end tests. An in-memory database can accept a query that production PostgreSQL rejects, while asking every developer to install and configure PostgreSQL by hand makes the suite harder to run.
Testcontainers launches disposable Docker containers for dependencies such as PostgreSQL, MySQL, Redis, and Kafka. A test can use the container’s JDBC URL to connect to a real database:
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Test
void connectsToRealPostgres() throws SQLException {
try (Connection connection = DriverManager.getConnection(
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())) {
assertThat(connection.isValid(1)).isTrue();
}
}
This guide explains how that test environment works, when its realism is worth the setup cost, and how to handle common CI and container failures.
TestContainers Architecture
TestContainers sits between your JUnit tests and Docker. When a test class annotated with @Testcontainers starts, the JUnit extension boots Docker containers defined by @Container fields. Each container initializes with its image, environment variables, exposed ports, and initialization scripts before any test method runs. After the test suite completes, TestContainers sends a stop signal and destroys the containers, freeing system resources.
graph TD
A["JUnit Test Class<br/>@Testcontainers"] --> B["Testcontainers JUnit5 Extension"]
B --> C["Docker Daemon"]
C --> D["PostgreSQL Container<br/>port: 5432"]
C --> E["Redis Container<br/>port: 6379"]
C --> F["MySQL Container<br/>port: 3306"]
D --> G["Ephemeral Volume<br/>data directory"]
E --> H["Ephemeral Volume<br/>data directory"]
F --> I["Ephemeral Volume<br/>data directory"]
J["Test Code"] --> K["JDBC Connection<br/>jdbc:postgresql://localhost:mapped"]
J --> L["Jedis/Redisson<br/>localhost:mapped"]
K --> D
L --> E
Your application connects through dynamically mapped ports. Docker assigns a random host port for each exposed container port, and TestContainers exposes these via getMappedPort(). The connection string points to localhost with the mapped port, exactly as it would in production.
Failure Scenarios
TestContainers depends on Docker being available and responsive. Several failure modes deserve attention.
Docker Not Available
If Docker is not running when your tests start, you get an IllegalStateException saying Docker is not reachable. Your CI pipeline must verify Docker is running as a precondition. Most CI environments need explicit startup commands or service initialization before TestContainers can use them.
// This throws IllegalStateException if Docker is not available
@Container
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:15-alpine");
Check Docker availability before running tests in CI by adding a precondition step.
Port Conflicts
Docker assigns random host ports when you expose container ports without explicit mapping. This prevents most conflicts, but if your machine runs services on the same ephemeral port range Docker chooses, startup can fail. Testcontainers assigns a random host port for an exposed container port, which avoids most collisions. Avoid binding a fixed host port unless a test specifically requires it; retrieve the assigned value with getMappedPort(5432).
Container Startup Timeout
Containers pulling large images on first run can exceed default startup timeouts. TestContainers defaults to sixty seconds, sufficient for most databases with pre-pulled images but may fail on cold CI runners or slow network connections.
Configure per-container startup timeout:
new PostgreSQLContainer("postgres:15-alpine")
.withStartupTimeout(Duration.ofSeconds(120));
Trade-off Analysis
| Dimension | TestContainers | Embedded Databases | In-Memory Mocks |
|---|---|---|---|
| Realism | Real engine and protocol behavior | Database engine (H2/MySQL mode) but not production-grade | Behavior only, no actual storage |
| Startup Time | 15-60 seconds (first run slower) | Sub-second | Near-instant |
| CI Compatibility | Requires Docker daemon | Works everywhere | Works everywhere |
| Maintenance | Image updates needed periodically | No maintenance | No maintenance |
| Database-Specific Features | Full support | Limited to compatibility modes | None |
| Network Behavior | Real TCP/IP, actual latency | In-process | None |
| Parallel Test Execution | Port conflicts possible | Safe | Safe |
| Resource Usage | CPU, memory, disk for containers | Minimal | Minimal |
TestContainers wins on realism and feature coverage. It loses on startup time and CI complexity. Embedded databases win on speed and portability. Choose based on what your tests actually need to verify.
Implementation
Basic PostgreSQL Setup
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
@Testcontainers
class PostgresRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
private Connection connection;
@BeforeEach
void setUp() throws SQLException {
connection = DriverManager.getConnection(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword()
);
}
@Test
void shouldInsertAndRetrieveUser() throws SQLException {
connection.createStatement().execute(
"INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com')"
);
var resultSet = connection.createStatement().executeQuery(
"SELECT * FROM users WHERE name = 'Alice'"
);
assertThat(resultSet.next()).isTrue();
assertThat(resultSet.getString("email")).isEqualTo("alice@example.com");
}
}
Basic MySQL Setup
import org.testcontainers.containers.MySQLContainer;
@Testcontainers
class MySqlRepositoryTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpassword");
@Test
void shouldConnectToMySQL() {
assertThat(mysql.getJdbcUrl()).contains("jdbc:mysql://");
assertThat(mysql.getDatabaseName()).isEqualTo("testdb");
}
}
GenericContainer for Custom Services
When a specialized container module does not exist, use GenericContainer:
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
@Testcontainers
class ElasticsearchIntegrationTest {
@Container
static GenericContainer<?> elasticsearch = new GenericContainer<>(
DockerImageName.parse("elasticsearch:8.11.0")
)
.withExposedPorts(9200, 9300)
.withEnv("discovery.type", "single-node")
.withEnv("xpack.security.enabled", "false")
.waitingFor(Wait.forHttp("/").forPort(9200));
@Test
void shouldConnectToElasticsearch() {
String host = elasticsearch.getHost();
Integer mappedPort = elasticsearch.getMappedPort(9200);
// connect to http://host:mappedPort
}
}
Custom Initialization Scripts
Initialize databases with schema and seed data before tests run by mounting initialization scripts:
@Container
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:15-alpine")
.withInitScript("db/init-schema.sql")
.withUsername("testuser")
.withPassword("testpassword");
Create the script file at src/test/resources/db/init-schema.sql:
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
product VARCHAR(255) NOT NULL,
quantity INTEGER NOT NULL,
order_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (name, email) VALUES
('Alice', 'alice@example.com'),
('Bob', 'bob@example.com');
Spring Boot Integration
TestContainers integrates smoothly with Spring Boot’s test slices:
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
@SpringBootTest
class OrderServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "none");
}
@Test
void shouldCreateOrderForExistingUser(@Autowired OrderService orderService) {
Order order = orderService.createOrder(1L, "Widget", 3);
assertThat(order.getId()).isNotNull();
}
}
The @DynamicPropertySource annotation injects container connection properties into the Spring context before the application starts, replacing whatever you have in application.properties.
Observability Checklist
When running integration tests against real containers, you need visibility into what is happening.
-
Container logs: Access via
container.getLogs()for debugging failures. Embedding logs in test reports saves time when diagnosing CI failures you cannot reproduce locally. -
Startup duration: Monitor how long containers take to initialize. Excessive startup times indicate image pull overhead or slow initialization scripts. Cache images in CI to keep feedback loops short.
-
Connection verification: Add explicit connection checks before running assertions. A container reporting “running” does not mean your application can query it yet.
@Test
void shouldWaitForDatabaseReadiness() {
await()
.atMost(30, TimeUnit.SECONDS)
.until(() -> {
try (Connection conn = DriverManager.getConnection(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword())) {
return conn.createStatement()
.executeQuery("SELECT 1")
.next();
} catch (SQLException e) {
return false;
}
});
}
- Query logging: In a Spring Boot test profile, set
spring.jpa.show-sql=trueandlogging.level.org.hibernate.SQL=DEBUGto capture executed queries. This helps identify N+1 problems or unexpected query patterns during integration tests.
Security Notes
Containers provide process isolation, but that isolation has limits worth understanding.
Container Isolation
Containers on the same Docker network can often reach each other by service name. If your test suite includes multiple containers (PostgreSQL, Redis, Kafka), they may be able to communicate through Docker’s internal DNS. This is generally safe for integration tests but differs from production network segmentation.
Use unique database names and credentials per test class when running tests in parallel:
@Container
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:15-alpine")
.withDatabaseName("test_" + UUID.randomUUID().toString().replace("-", ""))
.withUsername("test_" + UUID.randomUUID().toString().substring(0, 8))
.withPassword(UUID.randomUUID().toString());
Credential Management
Never hardcode credentials, even in test code. Use environment variables or externalized configuration:
.withUsername(Optional.ofNullable(System.getenv("POSTGRES_USER")).orElse("test"))
.withPassword(Optional.ofNullable(System.getenv("POSTGRES_PASSWORD")).orElse("test"));
For CI environments, inject secrets through the pipeline’s secret management system instead of passing them as plain text in configuration files.
Image Provenance
Pull images only from trusted registries. Specify exact image digests in production-critical CI to prevent supply chain attacks through image tags that get updated unexpectedly:
// Prefer digest over tag for reproducible builds
new PostgreSQLContainer("postgres@sha256:abc123...");
Common Pitfalls / Anti-Patterns
Resource Cleanup
TestContainers containers are automatically stopped when the JVM exits, but abrupt shutdowns can leave dangling containers or Docker processes. Prefer try-with-resources or explicit cleanup in @AfterAll:
@AfterAll
static void stopContainer() {
if (postgres != null && postgres.isRunning()) {
postgres.stop();
}
}
If your tests spawn many containers in sequence, Docker’s local image cache helps but disk space can accumulate. Run docker system prune periodically in CI to clean up unused images and volumes.
CI/CD Compatibility
TestContainers works on all major CI platforms (GitHub Actions, GitLab CI, Jenkins, CircleCI) but requires Docker-in-Docker or Docker socket mounting. GitHub Actions runners already have Docker installed; you may need to enable it explicitly:
services:
docker:
image: docker:20.10.16-dind
Jenkins agents need the Docker pipeline plugin or Docker-in-Docker configuration. Add a startup check step to verify Docker is available before running tests.
Parallel Test Execution
JUnit’s parallel execution combined with TestContainers requires careful port management. If two test classes both launch PostgreSQL containers without explicit port mapping, Docker assigns random ports and no conflict occurs. If multiple tests share one container or database state, they need their own synchronization and isolation strategy; Testcontainers does not provide a built-in @Shared annotation.
Use the @TestMethodOrder annotation with deterministic ordering if tests depend on shared state, or better yet, design tests to be independent and let TestContainers manage per-class container isolation.
Slow Image Pulls on Cold CI
First-run image pulls on CI runners without Docker layer caching can add minutes to your build. Pre-pull images in a CI setup step:
docker pull postgres:15-alpine
docker pull mysql:8.0
docker pull redis:7.0-alpine
Container reuse can reduce repeated startup work in local development, but Docker’s image cache is what avoids pulling an image again.
When NOT to Use
TestContainers is useful, but it is not always the right fit. Consider alternatives in these situations:
When Embedded or In-Memory Databases Suffice
If your tests only verify that a repository calls the correct SQL string, returns mapped entities, or handles basic CRUD operations, an in-memory database like H2 handles this without Docker overhead. When your tests need behavior that differs between H2 and your production database, that is when TestContainers becomes worthwhile. Until then, the added complexity is hard to justify.
When Tests Do Not Need Real Database Behavior
Some tests exercise business logic that happens to use a repository, without depending on any database-specific feature. In these cases, mocking the repository removes the need for any database at all. TestContainers does not help here. A well-designed unit test with proper mocking is faster, more deterministic, and easier to run in isolation.
When Docker Is Not Available in CI
Some CI environments, especially older self-hosted runners or certain containerized sandbox setups, either lack Docker or restrict its use. If your pipeline cannot run Docker containers reliably, TestContainers adds friction without benefit. Check whether your CI infrastructure can support Docker before deciding on TestContainers as a testing strategy.
When Startup Time Is Critical
TestContainers containers take seconds to start, even with cached images. For large test suites that must run in seconds, this cost multiplies across hundreds of test classes. If your team has strict fast-feedback requirements and your tests do not need real database behavior, an embedded alternative or pure mocking will outperform TestContainers.
When You Only Need to Verify Mock Interactions
If you only want to confirm that a service layer calls a repository method with specific arguments and handles the returned data correctly, mocking the repository and writing focused unit tests is simpler. TestContainers introduces real infrastructure for a problem that does not require it.
In short, reach for TestContainers when your tests must exercise real database behavior, real service interactions, or production-like conditions. When those requirements do not apply, the faster and lighter alternatives are usually preferable.
Quick Recap Checklist
Before shipping your TestContainers integration to production:
-
@Testcontainersannotates the test class -
@Containermarks each container field - Containers use specific image tags or digests, not
latest - Initialization scripts live in
src/test/resources/ - Connection properties injected via
@DynamicPropertySourcefor Spring Boot - Credentials come from environment variables, not hardcoded strings
- Container logs captured for CI failure diagnosis
- Startup timeout adjusted for cold CI runners
- Docker availability verified as a CI precondition
- Parallel test isolation understood and configured if needed
Testing Kafka with Testcontainers
Apache Kafka is a distributed streaming platform with complex startup sequences that make it an ideal candidate for TestContainers. The KafkaContainer module handles Zookeeper initialization and Kafka broker configuration automatically:
@Testcontainers
class KafkaConsumerIntegrationTest {
@Container
static KafkaContainer kafka = new KafkaContainer("apache/kafka:3.7.0")
.withEmbeddedZookeeper();
@Test
void shouldConsumeMessagesFromKafka() {
String bootstrapServers = kafka.getBootstrapServers();
KafkaProducer<String, String> producer = new KafkaProducer<>(
Map.of(
"bootstrap.servers", bootstrapServers,
"key.serializer", StringSerializer.class.getName(),
"value.serializer", StringSerializer.class.getName()
)
);
producer.send(new ProducerRecord<>("test-topic", "key", "value"));
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(
Map.of(
"bootstrap.servers", bootstrapServers,
"group.id", "test-group",
"key.deserializer", StringDeserializer.class.getName(),
"value.deserializer", StringDeserializer.class.getName(),
"auto.offset.reset", "earliest"
)
);
consumer.subscribe(List.of("test-topic"));
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(5));
assertThat(records.count()).isEqualTo(1);
assertThat(records.iterator().next().value()).isEqualTo("value");
}
}
Note that .withEmbeddedZookeeper() is required for Kafka versions prior to 3.6. Starting with Kafka 3.6 and later, KRaft mode eliminates the Zookeeper dependency, and you can use KafkaContainer without the embedded Zookeeper:
static KafkaContainer kafka = new KafkaContainer("apache/kafka:3.7.0");
For Spring Boot applications, register Kafka properties through @DynamicPropertySource:
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
Interview Questions
@Testcontainers annotation activates the TestcontainersExtension in JUnit 5. The extension launches containers marked with @Container before any test method runs and stops them after the test class finishes. You can control container lifecycle by annotating fields as static (shared across all test methods in the class) or instance fields (restarted between methods). For class-level containers, declare them as static. For method-level isolation, remove the static modifier. The extension respects test method ordering through @TestMethodOrder and handles exceptions gracefully, stopping containers even when tests fail.
@DynamicPropertySource annotation on a static method that registers container properties into Spring's DynamicPropertyRegistry. The method receives the registry as a parameter and adds entries like spring.datasource.url, spring.datasource.username, and spring.datasource.password using the container's getter methods as value suppliers. Spring Boot resolves these properties before the ApplicationContext initializes, so your DataSource autoconfiguration picks up the TestContainers connection details instead of whatever you have in application.yml. This approach works for any property Spring Boot consumes at startup, including JPA settings, Flyway migration configuration, and connection pool tuning.
withStartupTimeout() solves this.
@Container fields starts each container, but does not automatically place them on a shared user-defined network. To let containers address one another by network alias, create a Network and attach each container with withNetwork(network) and withNetworkAliases(...). Code running on the host should use each container's getHost() and getMappedPort(...) values instead. For Spring Boot, register each service's connection properties separately with @DynamicPropertySource.
SIGKILL or sudden power loss bypass this hook entirely, potentially leaving dangling containers on the Docker host. Docker does not automatically remove every orphaned container, so CI environments should use a controlled cleanup step if an abrupt termination leaves resources behind. The @AfterAll method or try-with-resources pattern provides explicit cleanup for the normal case, while docker system prune in CI pipelines handles the exceptional case. For critical CI workflows, add a post-build step that forcefully removes any remaining containers by pattern matching on naming conventions used in test classes.
withReuse(true) and local configuration. Reused containers can remain running after the test JVM exits and may be reused by later runs; they are not automatically removed at JVM shutdown. This can reduce startup time, but it also means tests must reset shared state deliberately. However, reuse introduces state leakage between tests if containers are not designed for idempotent initialization. Use reuse for long-running integration suites where container startup dominates execution time, but avoid it when tests modify database state in ways that conflict with other test classes. In CI, prefer @Container per-class isolation with image layer caching rather than container reuse, since parallel jobs cannot share reused containers anyway.
Wait.forHttp() waits for an HTTP endpoint to return a successful response; Wait.forPort() waits for a TCP port to be open; Wait.forLogMessage() waits for a specific log message pattern; Wait.forExposedPorts() waits for all declared ports to be accessible; and Wait.forSuccessfulCommand() runs a shell command and checks its exit code. Choose based on what the container exposes and how it signals readiness. Databases typically signal readiness through log messages or port availability, making Wait.forLogMessage() or Wait.forPort() appropriate. Application services with health endpoints work well with Wait.forHttp(). For custom services, you can combine multiple wait strategies or implement WaitStrategy directly. Always prefer the least brittle approach that still guarantees the service is ready to accept connections.
withInitScript() to load schema creation scripts before tests run, or configure Flyway or Liquibase through @DynamicPropertySource to point at the TestContainers database. Spring Boot applications register migration tools as usual, and @DynamicPropertySource injects the container's JDBC URL, username, and password before the ApplicationContext starts. This means Flyway or Liquibase runs against the real database engine automatically. For non-Spring applications, invoke the migration tool explicitly in @BeforeAll after the container starts, using the container's JDBC URL to establish a connection. This approach verifies that migration scripts work against the actual database engine with all its specific SQL dialect support.
-arm64 or :latest-arm64v8 where available. PostgreSQL, MySQL, and Redis all provide ARM-compatible images. If an image lacks ARM support, you may encounter slower container startup and higher CPU usage during initialization. In CI, x86_64 runners are the norm, so this rarely affects pipeline performance. Test your containers on the target architecture before assuming behavior is identical across platforms.
~/.docker/config.json or through docker login on CI runners. For corporate registries requiring custom CA certificates, ensure the Docker daemon has access to the registry CA and that the DOCKER_CERT_PATH environment variable points to the correct certificate directory. TestContainers also supports registryConfig and dockerConfig in the DockerClientConfig if you need programmatic configuration. In Kubernetes-based CI, image pull secrets are handled separately through Kubernetes ServiceAccount configurations, which Docker does not automatically consume—ensure your CI pod specification includes the appropriate imagePullSecrets.
container.getLogs() to retrieve stdout and stderr from the container, which often reveals initialization errors or service crashes. Use container.execInContainer() to run diagnostic commands inside the running container, such as checking database connection state or querying internal service metrics. For network issues, DockerLogsFinder and Wireshark on the Docker bridge interface help identify connection problems. In IntelliJ and Eclipse, the TestContainers extension provides a Docker view showing container status, port mappings, and log streams. For persistent debugging, start a container with a long startup timeout and a breakpoint in your test setup, then manually verify the service is responsive before continuing.
withInitScript() for schema and seed data that every test in the class requires, accepting that all tests share this baseline. For independent test data, give each test method a unique identifier prefix or suffix in table rows, then query with appropriate filters. Spring Boot applications can combine @Transactional on test classes with @Rollback(false) and explicit EntityManager operations for more granular control. Another pattern uses TestContainers' GenericContainer to launch a dedicated seed data container that produces a database backup or SQL dump, which subsequent containers restore via initialization scripts. Choose based on whether tests need shared baseline data or completely isolated datasets.
Further Reading
- API Unit, Integration, and Contract Testing — how integration tests fit into a broader test strategy
- Docker Fundamentals — images, containers, and the Docker daemon
- Testcontainers Documentation — Java modules, setup, and API reference
- Testcontainers Spring Boot Integration — framework-specific setup
- Testcontainers Java Repository — source and release information
- JUnit 5 User Guide — the extension model used by Testcontainers
- Docker Desktop — local Docker environment for development and testing
- Supported Testcontainers Images — supported Docker environments and image guidance
Conclusion
Testcontainers lets integration tests exercise real databases and services through disposable Docker containers. It is useful when database-specific behavior, wire protocols, or service configuration matter to the test; it adds startup time and requires a working Docker environment. Use it for the cases where a substitute could hide a production issue, and keep unit tests fast by mocking dependencies when infrastructure behavior is outside their scope.
Category
Related Posts
Spring Boot Build Tools: Maven & Gradle
Configure Maven and Gradle for Spring Boot projects—plugins, dependency management, packaging JARs and WARs, and build automation essentials.
Embedded Web Servers in Spring Boot: Tomcat, Jetty, Undertow
Configure embedded servers in Spring Boot: compare Tomcat, Jetty, and Undertow, tune thread pools, enable access logs, and switch implementations.
JUnit 5 & Jupiter: Lifecycle, Nested & Parameterized Tests
Explore JUnit 5 Jupiter features: master test lifecycle annotations, organize tests with @Nested, and parameterize tests with @CsvSource and @MethodSource.