Are you responsible for writing the JUnit or Spock tests in a Java project? Do you already have loads of unit tests that use mocks and somehow feel „I wonder if this code works in real life“?
Fret no more, Testcontainers has got you covered!

What is Testcontainers?

Testcontainers is an MIT-licensed Java library that enables you to easily launch and wire up Docker containers from within unit tests. These containers provide „real“ application instances that your system under test (SUT) depends upon, which would otherwise have to be mocked. As a result, you can use your favorite testing framework for automating tests „higher up“ the test pyramid, such as integration tests and UI tests.

The test pyramid

There are different flavors of tests pyramids out there, but they all share the same fundamental idea. The size of each segment indicates how many tests of each kind you should aim for. Typically, unit tests are „white box“ tests, whereas UI tests are „black box“ tests in which you don“™t care about how it is implemented, but what is implemented. Integration tests are often „grey box“ tests that have a limited knowledge about how things are implemented by testing against their interfaces and interactions instead. Generally, the cost of creating and maintaining tests increases the higher up you go in the pyramid, whereas their execution speed decreases.

Where unit tests mostly test small, relatively trivial units, integration tests depend on larger pieces of software or third-party tools and applications. As a result, automated integration tests are often built „“ if at all „“ against „test doubles“ (think stunt „doubles“ in movies), because using the „real“ object would be too cumbersome. There are „“ amongst others „“ several kinds of test doubles:

  • Mocks are throw-away objects usually created by a mocking framework that conform to the signature of the object they are impersonating. Their behavior is typically configured during the test, forming a specification of how the real object would allegedly behave.
  • Fakes are a real-world implementation of the interface of said objects, albeit with a much simpler implementation that suits the needs of the test(s), e.g. an in-memory database instead of the database used in production.

While using test doubles is perfectly valid in unit testing, it has some downsides in integration testing:

  • The confidence in the test is diminished: One can never be certain that the tested code would work equally well with the real objects, i.e. that the fake persistence service that uses an in-memory database behaves like the real service, which is backed by another database.
  • Not using the real object also incurs maintenance costs: If the specification of the real object changes
    • the implementations of fake objects have to be adapted accordingly.
    • the behavior of mocks has to be adapted in many places, as the specification of their behavior is often distributed across several tests.
  • Error conditions (networks being down etc.) are rarely tested, as anticipating how the real object would behave in such scenarios is very hard and implementing this behavior in a fake object is even harder.

Testcontainers tackles these problems by simplifying the usage of real objects by making it easy to satisfy their dependencies using Docker images. It offers several ready-to-use modules for various databases and other appliances, as well as the ability to create custom modules for applications not yet covered. In short: Anything you can „dockerize“, you can use with Testcontainers.

Basic Usage

You can either manage the lifecycle of your containers manually or use the provided annotations to do this work for you. The following code should give you a basic idea of how it works in JUnit 5 (the annotations for JUnit 4 are different, but conceptually equal):

@Testcontainers
public class RedisBackedCacheIntTest {

    private RedisBackedCache underTest;

    @Container
    public GenericContainer redis = new GenericContainer<>("redis:5.0.3-alpine").withExposedPorts(6379);

    @BeforeEach
    public void setUp() {
        String address = redis.getContainerIpAddress();
        Integer port = redis.getFirstMappedPort();

        // Now we have an address and port for Redis, no matter where it is running
        underTest = new RedisBackedCache(address, port);

        // Without Testcontainers, we would likely rely on a hard-coded instance that might not be reachable from anywhere the test is run
        // underTest = new RedisBackedCache("localhost", 6379); 
    }

    @Test
    public void testSimplePutAndGet() {
        underTest.put("test", "example");

        String retrieved = underTest.get("test");
        assertEquals("example", retrieved);
    }
}

The @Container annotation tells JUnit to notify this field about various events in the test lifecycle. In this case, a GenericContainer, configured to use a specific Redis image from Docker Hub, and configured to expose a port.

Behind the scenes Testcontainers:

  • was activated before our test method ran
  • discovered and quickly tested our local Docker setup
  • pulled the image if necessary
  • started a new container and waited for it to be ready
  • shut down and deleted the container after the test

Furthermore, it is possible to:

  • set up Docker networks for communication between containers
  • execute arbitrary commands in a container
  • map local files or files from the Java classpath into a container
  • wait for containers with custom applications to be ready
  • access the logs of a container
  • start custom containers specified in Dockerfiles

Learn more about some exciting use cases this powerful toolbox inspires in our follow-up post.

Alle Beiträge von Benjamin Thiel

Schreibe einen Kommentar