Richard North’s Blog

JUnit in­te­gra­tion test­ing with Docker and Testcontainers

This is the first of a cou­ple of re­lated posts; also see
Fun with Disque, Java and Spinach and
Better JUnit Selenium test­ing with Docker and Testcontainers
.

This brief ex­am­ple il­lus­trates one po­ten­tial us­age of the Testcontainers li­brary I’ve been work­ing on.

In brief, Testcontainers adds Docker sup­port to JUnit test suites, al­low­ing in­te­gra­tion test­ing Java code against tem­po­rary con­tain­ers.

This ex­am­ple cov­ers build­ing a re­ally sim­ple cache com­po­nent, backed by Redis. While 9 times out of 10 we’d want to mock de­pen­den­cies, to test the cache it­self there’s no sub­sti­tute for hook­ing it up to an ac­tual Redis in­stance. The com­po­nent is nec­es­sar­ily cou­pled to Redis, so we have to bite the bul­let and test against that to get real com­fort in our tests.

This is where Testcontainers comes in - it’s aimed at al­low­ing tests against real de­pen­den­cies while avoid­ing some of the down­sides:


You can find the source code used for this ex­am­ple, in­clud­ing Maven POMs which have been omit­ted for brevity, here. Also see the doc­u­men­ta­tion for how to add Testcontainers as a de­pen­dency to your pro­ject.

Our ex­am­ple in­ter­face and tests #

Let’s imag­ine we want to build a re­ally sim­ple cache - here’s our in­ter­face:

public interface Cache {

void put(String key, Object value);

<T> Optional<T> get(String key, Class<T> expectedClass);
}

And let’s de­fine some sim­ple unit tests (non-exhaustive and il­lus­tra­tive for the sake of our ex­am­ple, 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 ex­am­ple won’t even com­pile, so let’s im­ple­ment our RedisBackedCache class - this is go­ing to be our com­po­nent un­der test. Note that this is when we start to make some choices about the im­ple­men­ta­tion, such as us­ing Jedis as the Redis client li­brary and Gson for se­ri­al­i­sa­tion of stored ob­jects:

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 ac­tu­ally run #

We’re go­ing to need a Redis in­stance to ac­tu­ally test against. Let’s add one!

public class RedisBackedCacheTest {

@Rule
public GenericContainer redis = new GenericContainer("redis:3.0.6").withExposedPorts(6379);

...

The above ad­di­tion to our test class is a JUnit Rule, which tells Testcontainers to in­stan­ti­ate a new redis:3.0.6 con­tainer be­fore each test method is run, with one TCP port ex­posed to our test.

This way every test method gets a com­pletely clean in­stance of Redis, though if this is con­sid­ered too slow, @ClassRule can be used to in­stan­ti­ate the con­tainer once per test class in­stead.

Next, let’s cre­ate a Jedis client in a setUp method, and in­ject it into an in­stance of our RedisBackedCache. It will need to be con­nected to our Redis docker con­tainer - but Testcontainers helps make this rea­son­ably sim­ple:

@Before
public void setUp() throws Exception {
Jedis jedis = new Jedis(redis.getIpAddress(), redis.getMappedPort(6379));

cache = new RedisBackedCache(jedis, "test");
}

Here we’re us­ing the con­ve­nience meth­ods getIpAddress() and getMappedPort(...) on the con­tainer rule to tell us how to con­nect to the con­tainer.

The re­sult #

The out­come of this is:

  1. Before the test method starts, Testcontainers ini­tialises, lo­cat­ing the lo­cal Docker in­stal­la­tion via en­vi­ron­ment vari­ables.
  2. Next, Testcontainers pulls the Docker im­age (if nec­es­sary) and cre­ates an in­stance of the con­tainer
  3. Our test setUp method cre­ates the Jedis client in­stance, and in­jects it into a new in­stance of our com­po­nent un­der test
  4. The tests run, in­ter­act­ing with the com­po­nent and as­sert­ing that it be­haves as ex­pected. During this time, the RedisBackedCache is in­ter­act­ing with a real Redis server.
  5. After the test method fin­ishes, Testcontainers au­to­mat­i­cally ter­mi­nates the con­tainer and re­moves any vol­umes it cre­ated

A sum­marised form of the test logs is as fol­lows:

-------------------------------------------------------
 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 sim­plis­tic cache im­ple­men­ta­tion worked!

That’s the end of this ex­am­ple, but please have a closer look at Testcontainers and try it out!

Further read­ing #

← Home