JUnit integration testing with Docker and Testcontainers
This is the first of a couple of related posts; also see
Fun with Disque, Java and Spinach and
Better JUnit Selenium testing with Docker and Testcontainers.
This brief example illustrates one potential usage of the Testcontainers library I’ve been working on.
In brief, Testcontainers adds Docker support to JUnit test suites, allowing integration testing Java code against temporary containers.
This example covers building a really simple cache component, backed by Redis. While 9 times out of 10 we’d want to mock dependencies, to test the cache itself there’s no substitute for hooking it up to an actual Redis instance. The component is necessarily coupled to Redis, so we have to bite the bullet and test against that to get real comfort in our tests.
This is where Testcontainers comes in - it’s aimed at allowing tests against real dependencies while avoiding some of the downsides:
- Ensuring all developers and CI environments have the dependency installed
- Cleaning up state in between tests
- Environment differences that might affect test results or stability
You can find the source code used for this example, including Maven POMs which have been omitted for brevity, here. Also see the documentation for how to add Testcontainers as a dependency to your project.
Our example interface and tests #
Let’s imagine we want to build a really simple cache - here’s our interface:
public interface Cache {
void put(String key, Object value);
<T> Optional<T> get(String key, Class<T> expectedClass);
}
And let’s define some simple unit tests (non-exhaustive and illustrative for the sake of our example, but you’ll get the idea):
public class RedisBackedCacheTest {
private Cache cache;
@Before
public void setUp() throws Exception {
// jedis will be a Redis client we inject, but we don't have one to provide yet...
cache = new RedisBackedCache(jedis, "test");
}
@Test
public void testFindingAnInsertedValue() {
cache.put("foo", "FOO");
Optional<String> foundObject = cache.get("foo", String.class);
assertTrue("When an object in the cache is retrieved, it can be found", foundObject.isPresent());
assertEquals("When we put a String in to the cache and retrieve it, the value is the same", "FOO", foundObject.get());
}
@Test
public void testNotFindingAValueThatWasNotInserted() {
Optional<String> foundObject = cache.get("bar", String.class);
assertFalse("When an object that's not in the cache is retrieved, nothing is found", foundObject.isPresent());
}
}
At this stage the example won’t even compile, so let’s implement our RedisBackedCache
class - this is going to be our component under test. Note that this is when we start to make some choices about the implementation, such as using Jedis as the Redis client library and Gson for serialisation of stored objects:
public class RedisBackedCache implements Cache {
private final Jedis jedis;
private final String cacheName;
private final Gson gson;
public RedisBackedCache(Jedis jedis, String cacheName) {
this.jedis = jedis;
this.cacheName = cacheName;
this.gson = new Gson();
}
@Override
public void put(String key, Object value) {
String jsonValue = gson.toJson(value);
this.jedis.hset(this.cacheName, key, jsonValue);
}
@Override
public <T> Optional<T> get(String key, Class<T> expectedClass) {
String foundJson = this.jedis.hget(this.cacheName, key);
if (foundJson == null) {
return Optional.empty();
}
return Optional.of(gson.fromJson(foundJson, expectedClass));
}
}
Making the tests actually run #
We’re going to need a Redis instance to actually test against. Let’s add one!
public class RedisBackedCacheTest {
@Rule
public GenericContainer redis = new GenericContainer("redis:3.0.6").withExposedPorts(6379);
...
The above addition to our test class is a JUnit Rule, which tells Testcontainers to instantiate a new redis:3.0.6
container before each test method is run, with one TCP port exposed to our test.
This way every test method gets a completely clean instance of Redis, though if this is considered too slow, @ClassRule
can be used to instantiate the container once per test class instead.
Next, let’s create a Jedis client in a setUp
method, and inject it into an instance of our RedisBackedCache
. It will need to be connected to our Redis docker container - but Testcontainers helps make this reasonably simple:
@Before
public void setUp() throws Exception {
Jedis jedis = new Jedis(redis.getIpAddress(), redis.getMappedPort(6379));
cache = new RedisBackedCache(jedis, "test");
}
Here we’re using the convenience methods getIpAddress()
and getMappedPort(...)
on the container rule to tell us how to connect to the container.
The result #
The outcome of this is:
- Before the test method starts, Testcontainers initialises, locating the local Docker installation via environment variables.
- Next, Testcontainers pulls the Docker image (if necessary) and creates an instance of the container
- Our test
setUp
method creates the Jedis client instance, and injects it into a new instance of our component under test - The tests run, interacting with the component and asserting that it behaves as expected. During this time, the
RedisBackedCache
is interacting with a real Redis server. - After the test method finishes, Testcontainers automatically terminates the container and removes any volumes it created
A summarised form of the test logs is as follows:
-------------------------------------------------------
T E S T S
-------------------------------------------------------
Running RedisBackedCacheTest
20:23:09.922 [main] INFO org.testcontainers.SingletonDockerClient - Checking environment for docker settings
20:23:11.855 [main] INFO org.testcontainers.SingletonDockerClient - Found docker client settings from environment
20:23:11.855 [main] INFO org.testcontainers.SingletonDockerClient - Docker host IP address is 192.168.99.100
20:23:13.303 [main] INFO org.testcontainers.SingletonDockerClient - Disk utilization in Docker environment is 27%
20:23:13.676 [main] INFO 🐳 [redis:3.0.6] - Creating container for image: redis:3.0.6
20:23:13.798 [main] INFO 🐳 [redis:3.0.6] - Starting container with ID: 0c7d64e157a9b20c72bc02676c02515c45c12c2e951e92062e77651db473e667
20:23:13.980 [main] INFO 🐳 [redis:3.0.6] - Container redis:3.0.6 is starting: 0c7d64e157a9b20c72bc02676c02515c45c12c2e951e92062e77651db473e667
20:23:15.216 [main] INFO 🐳 [redis:3.0.6] - Container redis:3.0.6 started
✔ When an object that's not in the cache is retrieved, nothing is found
20:23:15.496 [main] INFO 🐳 [redis:3.0.6] - Stopped container: redis:3.0.6
20:23:15.568 [main] INFO 🐳 [redis:3.0.6] - Removed container and associated volume(s): redis:3.0.6
20:23:15.573 [main] INFO 🐳 [redis:3.0.6] - Creating container for image: redis:3.0.6
20:23:15.694 [main] INFO 🐳 [redis:3.0.6] - Starting container with ID: d1a96e1982b7472d8e8cc9fdfb8a7c47a109256f9ce34c2d871973894bc9c249
20:23:15.877 [main] INFO 🐳 [redis:3.0.6] - Container redis:3.0.6 is starting: d1a96e1982b7472d8e8cc9fdfb8a7c47a109256f9ce34c2d871973894bc9c249
20:23:17.290 [main] INFO 🐳 [redis:3.0.6] - Container redis:3.0.6 started
✔ When an object in the cache is retrieved, it can be found
✔ When we put a String in to the cache and retrieve it, the value is the same
20:23:17.489 [main] INFO 🐳 [redis:3.0.6] - Stopped container: redis:3.0.6
20:23:17.560 [main] INFO 🐳 [redis:3.0.6] - Removed container and associated volume(s): redis:3.0.6
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 8.485 sec
Our simplistic cache implementation worked!
That’s the end of this example, but please have a closer look at Testcontainers and try it out!
Further reading #
- Source code for this example
- Testcontainers documentation
- Visible assertions - note that this is what gives us a commentary of the assertions being run in the example output above!