2015-12-30

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:

  1. Before the test method starts, Testcontainers initialises, locating the local Docker installation via environment variables.
  2. Next, Testcontainers pulls the Docker image (if necessary) and creates an instance of the container
  3. Our test setUp method creates the Jedis client instance, and injects it into a new instance of our component under test
  4. 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.
  5. 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


testcontainers testing docker redis java


Previous post
Practical direnv is a nifty little shell tool I came across recently which looks extremely useful for developers who work across multiple languages or projects.
Next post
Fun with Disque, Java and Spinach This example continues similarly to a previous post, in which I used my Testcontainers tool to aid integration testing of Java code against a