Overview

The Traffic Parrot Testcontainers module starts Traffic Parrot inside your JUnit test, through Testcontainers. A TrafficParrotContainer builds a Docker image from a Traffic Parrot release zip (or uses an image you already have), starts it, waits until it is serving, and gives you the URL to point your code under test at. A TrafficParrotClient stubs, verifies and resets over the admin API from the same test. When the test finishes the container is gone, so every build starts from the same mappings and shares nothing with another pipeline. This is the service virtualization test container pattern.

You need:

  • Java 17 or later. The module and the example workspace compile for Java 17; the container carries a JRE of its own.
  • A Docker daemon that Testcontainers can reach and that runs Linux containers. A local Docker is the usual case; a remote daemon or Docker-in-Docker works too, with the copied traffic files described below.
  • A Traffic Parrot release zip, trafficparrot-no-jre-X.Y.Z.zip, on disk or at a URL your build can download, or a Traffic Parrot image you built yourself. Traffic Parrot publishes no public image.
  • Access to Maven Central to build the example workspace. The Traffic Parrot jar itself resolves from a repository inside the zip, so no Traffic Parrot server is contacted; a project of your own takes it from https://trafficparrot.com/repository instead, as Add it to an existing project shows.

No licence is needed to evaluate: a release zip starts without one. Testcontainers refuses to run Windows containers, so the example tests skip themselves on a Windows host.

Getting the module

Download the example workspace

The quickest start is the Testcontainers example workspace, a small Maven project that runs against a release zip with nothing else configured. Unzip it and run the two example tests with exactly one image source:

./mvnw test -Dtrafficparrot.release.zip=/path/to/trafficparrot-no-jre-X.Y.Z.zip
./mvnw test -Dtrafficparrot.release.zip=https://your-host/trafficparrot-no-jre-X.Y.Z.zip
./mvnw test -Dtrafficparrot.image=my-registry/trafficparrot:X.Y.Z

Set trafficparrot.release.zip or trafficparrot.image, never both and never neither: the examples fail with a message naming both properties rather than guessing. Set the TRAFFICPARROT_LICENSE environment variable to run with a licence; leave it unset to evaluate.

The zip contains:

  • pom.xml, with every version stated and no parent, so it builds on its own.
  • repo/, a file-based Maven repository holding the trafficparrot-testcontainers jar and pom. The pom's local-repository entry points at it, which is why the Traffic Parrot jar needs no repository of ours.
  • src/test/java, with a JUnit 5 example (@Testcontainers and @Container) and a JUnit 4 example (@Rule). Both run the same steps: start the container with a mapping file shipped under src/test/resources/traffic-files/mappings, stub one more mapping through the client, call both through the virtual service, verify that each was called once, and clear the request journal.
  • mvnw and mvnw.cmd, a short README.md and the LICENSE.

Add it to an existing project

The module is published to the Traffic Parrot Maven repository as com.trafficparrot:trafficparrot-testcontainers, one version per Traffic Parrot release, so a project of your own adds the repository and a test-scoped dependency on the version of the release it runs, and needs nothing from the workspace. Maven, in pom.xml:

<repositories>
    <repository>
        <id>trafficparrot.com</id>
        <url>https://trafficparrot.com/repository</url>
    </repository>
</repositories>

<dependency>
    <groupId>com.trafficparrot</groupId>
    <artifactId>trafficparrot-testcontainers</artifactId>
    <version>X.Y.Z</version>
    <scope>test</scope>
</dependency>

Gradle, in build.gradle.kts:

repositories {
    mavenCentral()
    maven("https://trafficparrot.com/repository")
}

dependencies {
    testImplementation("com.trafficparrot:trafficparrot-testcontainers:X.Y.Z")
}

A build with no route to https://trafficparrot.com/repository takes the same jar from the workspace instead: copy its repo/ directory next to your own pom.xml and point a repository entry at it. The version is the directory name under repo/com/trafficparrot/trafficparrot-testcontainers/; a Gradle build reads the same directory with maven { url = uri("repo") }.

<repositories>
    <repository>
        <id>local-repository</id>
        <url>file://${project.basedir}/repo</url>
        <releases>
            <checksumPolicy>ignore</checksumPolicy>
        </releases>
    </repository>
</repositories>

Testcontainers itself (org.testcontainers:testcontainers) comes with the module. What you add depends on the JUnit surface you use:

  • JUnit 5: org.testcontainers:junit-jupiter, for the @Testcontainers extension and @Container.
  • JUnit 4: junit:junit. If your build runs the JUnit Platform, as Surefire 3 does, also org.junit.vintage:junit-vintage-engine and org.hamcrest:hamcrest. Without the vintage engine a JUnit 4 class is discovered by nothing: it contributes zero test runs and the build stays green.

The workspace pom.xml declares all of these, so it is the reference for the versions that go together.

Starting Traffic Parrot

From a release zip

fromReleaseZip takes the release zip as a Path on the local filesystem or as a URI to download, and builds an image from it: the zip is unpacked into /opt/trafficparrot on eclipse-temurin:21-jre and started with start-foreground.sh, with the management port set to 2003 and the virtual service HTTP port to 2001 inside the container. No image has to exist anywhere first.

TrafficParrotContainer fromLocalZip = TrafficParrotContainer
        .fromReleaseZip(Paths.get("/downloads/trafficparrot-no-jre-X.Y.Z.zip"));

TrafficParrotContainer fromRemoteZip = TrafficParrotContainer
        .fromReleaseZip(URI.create("https://your-host/trafficparrot-no-jre-X.Y.Z.zip"));

TrafficParrotContainer onAnotherBaseImage = TrafficParrotContainer
        .fromReleaseZip(Paths.get("/downloads/trafficparrot-no-jre-X.Y.Z.zip"), "eclipse-temurin:17-jre");

TrafficParrotContainer fromAnExistingImage = TrafficParrotContainer
        .fromImageName("my-registry/trafficparrot:X.Y.Z")
        .withInstallDirectory("/opt/trafficparrot");

A zip that is not there fails at the fromReleaseZip call, naming the path. A remote zip is downloaded to a temporary file first; the download has a 30 second connect timeout and a 60 second read timeout, so an unreachable host fails rather than hanging the test run.

The two-argument overloads take a base image of your choosing. It must be an apt-based image (the Debian and Ubuntu family): the generated Dockerfile installs unzip with apt-get, so an Alpine image fails while the image is being built, with apt-get: not found.

The image is tagged localhost/trafficparrot/release: followed by a digest of the zip's path or URL, the base image and the two ports, and it is left on the host when the JVM exits. A later run with the same zip rebuilds it from Docker's layer cache in moments; a run with a new zip moves the tag and removes the image it superseded, so builds do not pile up.

From an image you already have

fromImageName uses an existing image. The image is expected to start Traffic Parrot with the management port on 2003 and the virtual service HTTP port on 2001. If it installs Traffic Parrot somewhere other than /opt/trafficparrot, say so with withInstallDirectory: the licence file and every traffic-files directory described below are placed relative to that directory.

Ports and URLs

Both ports are published on host ports Docker picks, so two containers never collide. start(), or the JUnit lifecycle that calls it for you, returns once GET /__admin on the virtual service port answers 200, and gives up after three minutes. From then on:

trafficParrot.start();

String virtualServiceUrl = trafficParrot.getVirtualServiceUrl();  // http://localhost:<mapped 2001>
String managementUrl = trafficParrot.getManagementUrl();          // http://localhost:<mapped 2003>
String adminUrl = trafficParrot.getAdminUrl();                    // virtualServiceUrl + /__admin
  • getVirtualServiceUrl() is where the code under test points. getVirtualServicePort() is the host port on its own.
  • getManagementUrl() is the web UI and the management API, with getManagementPort() for the port.
  • getAdminUrl() is the WireMock-native admin API, which Traffic Parrot serves on the virtual service port under /__admin, not on the management port. It is what TrafficParrotClient talks to.

Supplying mappings and other traffic files

Traffic Parrot reads its traffic files on boot, so a mapping supplied before the container starts is being served the moment start() returns, with no admin call. There are two ways to supply them, and the difference matters:

TrafficParrotContainer http = TrafficParrotContainer.fromReleaseZip(zip)
        .withMappings(Paths.get("src/test/resources/mappings"))
        .withFiles(Paths.get("src/test/resources/__files"))
        .withScenarios(Paths.get("src/test/resources/scenarios"));

// a whole traffic-files root, per-protocol mapping directories included
TrafficParrotContainer everyProtocol = TrafficParrotContainer.fromReleaseZip(zip)
        .withTrafficFiles(Paths.get("src/test/resources/traffic-files"));

// a copy of packaged resources: writable, and no shared filesystem with the Docker daemon needed
TrafficParrotContainer packaged = TrafficParrotContainer.fromReleaseZip(zip)
        .withPackagedTrafficFiles(MountableFile.forClasspathResource("traffic-files"));
Method What it does
withMappings(Path) Mounts the directory read-only at mappings/ under the traffic-files root, so the HTTP mapping files in it are served.
withFiles(Path) Mounts it read-only at __files/, the static response bodies that mappings reference by bodyFileName.
withScenarios(Path) Mounts it read-only at scenarios/.
withTrafficFiles(Path) Mounts each subdirectory of a whole traffic-files root, read-only and one by one, so jms-mappings/, grpc-mappings/, ibm-mq-mappings/, websocket-mappings/, file-mappings/ and any other per-protocol directory reach the container too. This is the only way to supply mappings for a protocol other than HTTP. Loose files directly in the root are not mounted, and the subdirectories are read when the container starts, not when the method is called.
withPackagedTrafficFiles(MountableFile) Copies the files into the traffic-files root when the container starts. Use it for fixtures packaged with the tests, typically MountableFile.forClasspathResource("traffic-files"). The copy is writable, and it needs no filesystem shared with the Docker daemon, so it works against a remote daemon or Docker-in-Docker, where a bind mount would come up empty. It is taken once, so an edit made after the container started is not seen.

Every mount is read-only on purpose: Traffic Parrot writes into its traffic-files root when it records, and a test container silently writing into your source directory is not something to allow by default. One consequence to know about: TrafficParrotClient.stub persists the new mapping into that same mappings/ directory, so on a container whose mappings/ is a read-only mount (through withMappings, or withTrafficFiles on a root that has a mappings subdirectory) the call fails with HTTP 500 and Read-only file system. When a test both ships mappings and stubs at runtime, supply the files with withPackagedTrafficFiles, as the examples do.

A directory that does not exist fails at the with call, naming the path, rather than later when Docker cannot find it.

Licensing

A release zip starts without a licence, which is enough to evaluate the module. To run with your licence, supply it in one of two ways, not both:

TrafficParrotContainer licensed = TrafficParrotContainer.fromReleaseZip(zip)
        .withLicense(System.getenv("TRAFFICPARROT_LICENSE"));

TrafficParrotContainer licensedFromAFile = TrafficParrotContainer.fromReleaseZip(zip)
        .withLicenseFile(Paths.get("trafficparrot.license"));
  • withLicense(String) takes the licence as base64 text and passes it in the TRAFFICPARROT_LICENSE environment variable, which Traffic Parrot reads ahead of any licence file. It does not depend on where the image installs Traffic Parrot, so prefer it.
  • withLicenseFile(Path) copies a licence file to trafficparrot.license in the install directory, over the one in the image.

Calling both throws IllegalStateException at the second call: the environment variable wins, so the file you supplied would otherwise be ignored without a word. The example workspace passes TRAFFICPARROT_LICENSE through from the host environment when it is set.

Stubbing, verifying and resetting

TrafficParrotClient talks to the admin API. Get one from the container after it has started, with client(), or build one against any Traffic Parrot with TrafficParrotClient.connectedTo(adminUrl). It depends on nothing but the JDK, so mappings and request patterns are passed as JSON text, in the same formats you already use in mapping files.

TrafficParrotClient client = trafficParrot.client();

client.stub("{\"request\":{\"url\":\"/orders/1\",\"method\":\"GET\"},"
        + "\"response\":{\"status\":200,\"body\":\"order 1\"}}");

// ... exercise the code under test against trafficParrot.getVirtualServiceUrl() ...

int calls = client.countRequests("{\"url\":\"/orders/1\",\"method\":\"GET\"}");

client.resetRequests();   // empties the request journal, keeps every mapping
client.reset();           // clears every mapping and the journal, see below
Method What it does
stub(String mappingJson) Registers a mapping, in the same JSON as a file under mappings/ (see HTTP). A mapping Traffic Parrot rejects throws IllegalStateException carrying the status and body it answered, rather than silently never matching.
countRequests(String requestPatternJson) Counts the requests in the journal that match a pattern, in the same JSON as the request section of a mapping (see Request matching). If the request journal is disabled it throws rather than returning a zero that could mean either "not called" or "not recorded".
resetRequests() Empties the request journal and keeps every mapping. This is the one to call between tests: counts start from zero again and the traffic files you supplied stay in place.
reset() Clears every mapping and the journal, and that includes deleting the mapping files inside the container, the ones your traffic files supplied among them. Static bodies under __files stay. There is no way to load the mappings again for the life of that container, so reach for a fresh container when you need the fixtures back. A fixture that came from a multi-mapping file (one JSON file holding several mappings) goes with the rest: the file is deleted whole, with every mapping it holds.

JUnit 5

With org.testcontainers:junit-jupiter on the classpath, annotate the class @Testcontainers and the field @Container, and the extension owns the lifecycle: no start(), no try-with-resources, no teardown. A static field is started once for the class; an instance field is started before each test. This runs the same steps as the workspace's JUnit 5 example, in one file, with the release zip taken from the trafficparrot.release.zip system property:

package com.example;

import com.trafficparrot.testcontainers.TrafficParrotClient;
import com.trafficparrot.testcontainers.TrafficParrotContainer;
import org.junit.jupiter.api.Test;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.MountableFile;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Paths;

import static org.junit.jupiter.api.Assertions.assertEquals;

@Testcontainers
class TrafficParrotJupiterTest {

    // static: one container for the whole class, started before the first test and stopped after
    // the last. The traffic files are copied in before it starts, so the mapping files under
    // src/test/resources/traffic-files/mappings are being served as soon as start() returns.
    @Container
    static final TrafficParrotContainer TRAFFIC_PARROT = TrafficParrotContainer
            .fromReleaseZip(Paths.get(System.getProperty("trafficparrot.release.zip")))
            .withPackagedTrafficFiles(MountableFile.forClasspathResource("traffic-files"));

    @Test
    void servesAShippedAndAStubbedMapping() throws Exception {
        TrafficParrotClient client = TRAFFIC_PARROT.client();
        client.stub("{\"request\":{\"url\":\"/stubbed\",\"method\":\"GET\"},"
                + "\"response\":{\"status\":200,\"body\":\"served from a mapping stubbed at runtime\"}}");

        assertEquals("served from a mapping file shipped with the tests", get("/shipped"));
        assertEquals("served from a mapping stubbed at runtime", get("/stubbed"));

        assertEquals(1, client.countRequests("{\"url\":\"/shipped\",\"method\":\"GET\"}"));
        assertEquals(1, client.countRequests("{\"url\":\"/stubbed\",\"method\":\"GET\"}"));

        // Empties the request journal and keeps every mapping, so the next test counts from zero.
        client.resetRequests();
    }

    private static String get(String path) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(TRAFFIC_PARROT.getVirtualServiceUrl() + path))
                .GET()
                .build();
        HttpResponse<String> response = HttpClient.newHttpClient()
                .send(request, HttpResponse.BodyHandlers.ofString());
        assertEquals(200, response.statusCode());
        return response.body();
    }
}

The mapping file it expects, src/test/resources/traffic-files/mappings/get-shipped.json:

{
  "name" : "get-shipped.json",
  "request" : {
    "url" : "/shipped",
    "method" : "GET"
  },
  "response" : {
    "status" : 200,
    "body" : "served from a mapping file shipped with the tests"
  }
}

JUnit 4

Declare the container as a @Rule and JUnit 4 starts it before each test and stops it afterwards. Testcontainers' GenericContainer already implements JUnit 4's TestRule, so TrafficParrotContainer has that surface without an adapter. A @ClassRule on a static field starts it once for the class instead. Remember the vintage engine if your build runs the JUnit Platform.

package com.example;

import com.trafficparrot.testcontainers.TrafficParrotClient;
import com.trafficparrot.testcontainers.TrafficParrotContainer;
import org.junit.Rule;
import org.junit.Test;
import org.testcontainers.utility.MountableFile;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Paths;

import static org.junit.Assert.assertEquals;

public class TrafficParrotJUnit4Test {

    // A @Rule starts the container before each test and stops it afterwards. Testcontainers'
    // GenericContainer implements JUnit 4's TestRule, so no Traffic Parrot adapter is involved.
    @Rule
    public final TrafficParrotContainer trafficParrot = TrafficParrotContainer
            .fromReleaseZip(Paths.get(System.getProperty("trafficparrot.release.zip")))
            .withPackagedTrafficFiles(MountableFile.forClasspathResource("traffic-files"));

    @Test
    public void servesAShippedAndAStubbedMapping() throws Exception {
        TrafficParrotClient client = trafficParrot.client();
        client.stub("{\"request\":{\"url\":\"/stubbed\",\"method\":\"GET\"},"
                + "\"response\":{\"status\":200,\"body\":\"served from a mapping stubbed at runtime\"}}");

        assertEquals("served from a mapping file shipped with the tests", get("/shipped"));
        assertEquals("served from a mapping stubbed at runtime", get("/stubbed"));

        assertEquals(1, client.countRequests("{\"url\":\"/shipped\",\"method\":\"GET\"}"));
        assertEquals(1, client.countRequests("{\"url\":\"/stubbed\",\"method\":\"GET\"}"));
    }

    private String get(String path) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(trafficParrot.getVirtualServiceUrl() + path))
                .GET()
                .build();
        HttpResponse<String> response = HttpClient.newHttpClient()
                .send(request, HttpResponse.BodyHandlers.ofString());
        assertEquals(200, response.statusCode());
        return response.body();
    }
}

One Traffic Parrot for the whole suite

The examples above start a container per test class. A suite of many classes can share one instead: hold it in a static field started in a static initialiser, the Testcontainers singleton pattern. The JVM runs the initialiser once, the first time any test class reads the field, so every class talks to the same container whichever of them runs first, and nothing stops it by hand: Testcontainers removes it when the JVM exits, through its Ryuk container, or through a JVM shutdown hook where Ryuk is disabled. One start per JVM is one per test fork: with Surefire's forkCount above 1, or reuseForks set to false, each fork starts its own.

This fits when the job that runs the tests also starts the application under test, so the application can be pointed at a Traffic Parrot on a port Docker picked; an application that runs in a shared test environment needs Traffic Parrot at a fixed, reachable address, and a startup script or Docker stays the right tool for that.

The holder, from the example workspace. The mapping it serves is a packaged traffic file, so it is live from boot and no test has to stub it:

package com.example;

import com.trafficparrot.testcontainers.TrafficParrotContainer;
import org.slf4j.LoggerFactory;
import org.testcontainers.utility.MountableFile;

import java.nio.file.Paths;

// One Traffic Parrot for every test class in the JVM. The static initialiser runs once, the first
// time any test class reads TRAFFIC_PARROT, and nothing stops the container by hand: Testcontainers
// removes it when the JVM exits.
final class SharedTrafficParrot {

    static final TrafficParrotContainer TRAFFIC_PARROT = start();

    private static TrafficParrotContainer start() {
        TrafficParrotContainer trafficParrot = TrafficParrotContainer
                .fromReleaseZip(Paths.get(System.getProperty("trafficparrot.release.zip")))
                .withPackagedTrafficFiles(MountableFile.forClasspathResource("shared-suite-traffic-files"));
        trafficParrot.start();
        // Once per JVM: a run that prints this line once has shared one container across its classes.
        LoggerFactory.getLogger(SharedTrafficParrot.class)
                .info("Started the shared Traffic Parrot, container {}", trafficParrot.getContainerId());
        return trafficParrot;
    }
}

src/test/resources/shared-suite-traffic-files/mappings/get-order.json answers with whatever key the request carries:

{
  "name" : "get-order.json",
  "request" : {
    "urlPathPattern" : "/orders/[^/]+",
    "method" : "GET"
  },
  "response" : {
    "status" : 200,
    "body" : "order {{request.pathSegments.[1]}}"
  }
}

A test class that uses it. The workspace has two, SharedSuiteOrdersTest and SharedSuiteReportsTest, and neither starts or stops anything:

package com.example;

import com.trafficparrot.testcontainers.TrafficParrotContainer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.UUID;

import static org.junit.jupiter.api.Assertions.assertEquals;

class SharedSuiteOrdersTest {

    private static final TrafficParrotContainer TRAFFIC_PARROT = SharedTrafficParrot.TRAFFIC_PARROT;

    @BeforeEach
    void countRequestsFromZero() {
        // Empties the request journal only; the packaged mappings stay.
        TRAFFIC_PARROT.client().resetRequests();
    }

    @Test
    void anOrderThisTestCreatedIsServedByTheSharedTrafficParrot() throws Exception {
        String orderId = UUID.randomUUID().toString();

        assertEquals("order " + orderId, get("/orders/" + orderId));

        // Exactly one request with this test's key, whatever else ran on the shared instance.
        assertEquals(1, TRAFFIC_PARROT.client()
                .countRequests("{\"url\":\"/orders/" + orderId + "\",\"method\":\"GET\"}"));
    }

    private static String get(String path) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(TRAFFIC_PARROT.getVirtualServiceUrl() + path))
                .GET()
                .build();
        HttpResponse<String> response = HttpClient.newHttpClient()
                .send(request, HttpResponse.BodyHandlers.ofString());
        assertEquals(200, response.statusCode());
        return response.body();
    }
}

Three things keep the tests independent on a shared instance:

  • The mappings the suite relies on are packaged traffic files, served from boot, so no test has to stub them and none can remove them.
  • Each test makes up its own key, so tests never read each other's data, and the mapping echoes the key back.
  • Each test calls resetRequests() first, so request counts start from zero. That empties the request journal only. Prefer reset() only when tests stub at runtime and must not see each other's stubs, remembering that it also deletes the packaged mapping files for the life of the container.

Both resets act on every class sharing the container. Test classes run one after another by default, which the reset relies on; if you run them in parallel, drop the reset and count by your unique key alone, which these tests already do. If the container fails to start, the first class to touch it fails with an ExceptionInInitializerError carrying the cause, and every later class with a NoClassDefFoundError: read the first failure.

Adding your extensions and configuration

A Traffic Parrot that needs your extension, the JDBC driver for your database and a database-connections.json of your own does not need an edited release zip: all three go in before the container starts. This fits when the job that runs the tests also starts the application under test, so the application can be pointed at a Traffic Parrot on a port Docker picked; for a Traffic Parrot you start yourself, at a fixed address, the same three go in through the lib/external directory and a property override, as Adding an extension jar and configuration without rebuilding the zip describes.

TrafficParrotContainer trafficParrot = TrafficParrotContainer.fromReleaseZip(zip)
        .withExtensionJar(Paths.get("target/my-extension.jar"))
        .withExtensionJar(Paths.get("drivers/postgresql.jar"))
        .withProperty("trafficparrot.http.responsetransformers", "...the four shipped transformers..., com.example.MyTransformer")
        .withPackagedTrafficFiles(MountableFile.forClasspathResource("traffic-files"));
Method What it does
withExtensionJar(Path) Copies the jar into lib/external/ under the install directory before the container starts; that directory is on Traffic Parrot's classpath. It is a copy, like withLicenseFile, so it works against a remote daemon or Docker-in-Docker. Supply a JDBC driver the same way: Traffic Parrot ships none for your database. A path that is not a readable .jar file fails at the call, naming it, and so does a second jar with the same file name, which would otherwise replace the first.
withProperty(String key, String value) Adds a key=value override to the start command, as if the property were set in trafficparrot.properties. It needs the start command this module wrote, so it works on a container from fromReleaseZip and is refused at the call on one from fromImageName, whose start command is unknown here: pass properties the way that image expects. Also refused at the call, where each would otherwise show up only as a readiness timeout: a key with anything but letters, digits and dots, the only keys the launcher accepts; either of the two port properties this container publishes and waits on; and a value spanning lines. Overriding a list property replaces the whole list, so registering an HTTP response transformer means listing the four Traffic Parrot ships as well as yours.

Configuration files need no method of their own: a database-connections.json among the packaged traffic files lands in the traffic-files root, where Traffic Parrot reads it.

The worked example from the workspace puts the three together. It is shared per JVM exactly like SharedTrafficParrot above. The extension is a small HTTP response transformer compiled with the tests and written into a jar by the workspace's ExtensionJar helper, which copies the compiled class and the nested classes it compiles to into a jar in a temporary directory, so the suite still runs with one ./mvnw test and no packaging step. An extension with more classes than that is better built as its own Maven module and supplied as the jar it produces. The JDBC driver is the H2 jar the test classpath already has, found with ExtensionJar.containing, the jar a class was loaded from, so nothing is downloaded or copied by hand.

package com.example;

import com.trafficparrot.testcontainers.TrafficParrotContainer;
import org.testcontainers.utility.MountableFile;

import java.nio.file.Paths;

final class ExtensionAndDatabaseTrafficParrot {

    private static final String SHIPPED_HTTP_RESPONSE_TRANSFORMERS =
            "com.trafficparrot.virtualservice.extensions.responsetransformer.UpdateContentLengthTransformer,"
                    + "com.trafficparrot.virtualservice.extensions.responsetransformer.HlsStreamTransformer,"
                    + "com.trafficparrot.virtualservice.extensions.responsetransformer.Base64DecodeBodyResponseTransformer,"
                    + "com.trafficparrot.virtualservice.extensions.responsetransformer.HmacSigningTransformer";

    static final TrafficParrotContainer TRAFFIC_PARROT = start();

    private static TrafficParrotContainer start() {
        TrafficParrotContainer trafficParrot = TrafficParrotContainer
                .fromReleaseZip(Paths.get(System.getProperty("trafficparrot.release.zip")))
                // The extension, jarred from the compiled test classes.
                .withExtensionJar(ExtensionJar.of(UpperCaseBodyTransformer.class))
                // The JDBC driver the database connection needs: the jar it was loaded from.
                .withExtensionJar(ExtensionJar.containing(org.h2.Driver.class))
                // Overriding a list property replaces the whole list, so the four shipped transformers stay.
                .withProperty("trafficparrot.http.responsetransformers",
                        SHIPPED_HTTP_RESPONSE_TRANSFORMERS + "," + UpperCaseBodyTransformer.class.getName())
                .withPackagedTrafficFiles(MountableFile.forClasspathResource("extension-traffic-files"));
        trafficParrot.start();
        return trafficParrot;
    }
}

The transformer. It runs inside Traffic Parrot, not in the test JVM, and only for a mapping that names it in transformers, by its class name with a lower-case first letter:

package com.example;

import com.github.tomakehurst.wiremock.common.FileSource;
import com.github.tomakehurst.wiremock.extension.Parameters;
import com.github.tomakehurst.wiremock.http.Request;
import com.github.tomakehurst.wiremock.http.Response;
import com.trafficparrot.sdk.http.HttpResponseTransformer;

import java.util.Locale;

public class UpperCaseBodyTransformer extends HttpResponseTransformer {

    @Override
    protected Response doTransform(Request request, Response response, FileSource fileSource, Parameters parameters) {
        return Response.Builder.like(response)
                .but()
                .body(response.getBodyAsString().toUpperCase(Locale.ROOT))
                .build();
    }
}

The packaged traffic files under src/test/resources/extension-traffic-files. The connection's JDBC URL creates the table itself, standing in for your own start-up database script:

// database-connections.json
[
  {
    "connectionId" : "suite.db",
    "type" : "JDBC_CONNECTION",
    "driverClass" : "org.h2.Driver",
    "jdbcUrl" : "jdbc:h2:mem:suite;DB_CLOSE_DELAY=-1;INIT=CREATE TABLE IF NOT EXISTS RECORD(ID VARCHAR(64) PRIMARY KEY, NAME VARCHAR(255))",
    "properties" : {
      "user" : "sa",
      "password" : "sa"
    }
  }
]

// mappings/get-shout.json
{
  "name" : "get-shout.json",
  "request" : {
    "urlPathPattern" : "/shout/[^/]+",
    "method" : "GET"
  },
  "response" : {
    "status" : 200,
    "body" : "you said {{request.pathSegments.[1]}}",
    "transformers" : [ "upperCaseBodyTransformer" ]
  }
}

// mappings/post-record.json
{
  "name" : "post-record.json",
  "request" : {
    "urlPathPattern" : "/records/[^/]+",
    "method" : "POST"
  },
  "response" : {
    "status" : 201,
    "body" : "{{ dataSource 'suite.db' 'INSERT INTO RECORD(ID, NAME) VALUES(:1, :2)' request.pathSegments.[1] request.body }}"
  }
}

// mappings/get-record.json
{
  "name" : "get-record.json",
  "request" : {
    "urlPathPattern" : "/records/[^/]+",
    "method" : "GET"
  },
  "response" : {
    "status" : 200,
    "body" : "{{ dataSource 'suite.db' 'SELECT NAME FROM RECORD WHERE ID = :1' request.pathSegments.[1] single=true default='no such record' }}"
  }
}

Two tests prove each part: the transformer changes a response, and a row inserted through the dataSource helper is read back from the database the connection names:

package com.example;

import org.junit.jupiter.api.Test;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.UUID;

import static org.junit.jupiter.api.Assertions.assertEquals;

class ExtensionAndDatabaseExampleTest {

    @Test
    void theTransformerFromTheExtensionJarChangesTheResponse() throws Exception {
        assertEquals("YOU SAID HELLO", send(HttpRequest.newBuilder().GET(), "/shout/hello").body());
    }

    @Test
    void aRowInsertedThroughTheDataSourceHelperIsReadBack() throws Exception {
        String recordId = UUID.randomUUID().toString();

        HttpResponse<String> inserted = send(HttpRequest.newBuilder()
                .POST(HttpRequest.BodyPublishers.ofString("name-" + recordId)), "/records/" + recordId);
        assertEquals(201, inserted.statusCode());

        assertEquals("name-" + recordId, send(HttpRequest.newBuilder().GET(), "/records/" + recordId).body());
    }

    private static HttpResponse<String> send(HttpRequest.Builder request, String path) throws Exception {
        String virtualServiceUrl = ExtensionAndDatabaseTrafficParrot.TRAFFIC_PARROT.getVirtualServiceUrl();
        return HttpClient.newHttpClient()
                .send(request.uri(URI.create(virtualServiceUrl + path)).build(), HttpResponse.BodyHandlers.ofString());
    }
}

The extension here does not open a database itself; one that reads or writes through Traffic Parrot's own connections is the next step, in Using Traffic Parrot's database connections.

From a Cypress suite

A Node suite can do the same with testcontainers from npm, with no Java involved. cypress.config.js builds the image from the Dockerfile below, the one this module generates, starts Traffic Parrot once in setupNodeEvents before the first spec, with your jars and traffic files copied in and your properties on the start command, hands its URL to every spec through the returned config, and stops it on after:run. This fits when the job that runs the specs also starts the application under test, so the application can be pointed at a Traffic Parrot on a port Docker picked; an application that runs in a shared test environment needs Traffic Parrot at a fixed, reachable address, and a startup script or Docker stays the right tool for that.

It needs Cypress 15.10 or later, for Cypress.expose (Cypress 16 removed Cypress.env; on an older Cypress set config.env in the same place and read Cypress.env('trafficParrotUrl') in the specs), Node 22.22 or later for testcontainers 12, and this layout next to cypress.config.js:

  • trafficparrot/image/: the Dockerfile and the release zip renamed trafficparrot.zip, and nothing else, because the whole directory is sent to Docker as the build context.
  • trafficparrot/extensions/: your extension jars and the JDBC drivers they need. Every .jar there is copied into lib/external, which is on Traffic Parrot's classpath.
  • trafficparrot/traffic-files/: mappings/ (the test below needs a copy of the get-order.json mapping shown earlier in it), __files/, database-connections.json and so on, merged into the install directory, which is also the traffic-files root.

package.json:

{
  "private": true,
  "scripts": {
    "test": "cypress run"
  },
  "engines": {
    "node": ">=22.22"
  },
  "devDependencies": {
    "cypress": "16.1.1",
    "testcontainers": "12.2.0"
  }
}

trafficparrot/image/Dockerfile:

FROM eclipse-temurin:21-jre
RUN apt-get update && apt-get install -y unzip && rm -rf /var/lib/apt/lists/*
WORKDIR /opt
COPY trafficparrot.zip trafficparrot.zip
RUN unzip -q trafficparrot.zip && rm trafficparrot.zip && mv trafficparrot-* trafficparrot
WORKDIR /opt/trafficparrot
EXPOSE 2003 2001
CMD exec ./start-foreground.sh \
    trafficparrot.gui.http.port=2003 \
    trafficparrot.virtualservice.http.port=2001

cypress.config.js:

const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { defineConfig } = require('cypress');
const { GenericContainer, Wait } = require('testcontainers');

const INSTALL_DIRECTORY = '/opt/trafficparrot';
const MANAGEMENT_PORT = 2003; // the web UI
const VIRTUAL_SERVICE_PORT = 2001; // virtual service HTTP and the /__admin API

// Overriding a list property replaces the whole list, so keep the four response transformers Traffic
// Parrot ships and add yours. This one is the example transformer from the Testcontainers workspace.
const PROPERTIES = {
  'trafficparrot.http.responsetransformers': [
    'com.trafficparrot.virtualservice.extensions.responsetransformer.UpdateContentLengthTransformer',
    'com.trafficparrot.virtualservice.extensions.responsetransformer.HlsStreamTransformer',
    'com.trafficparrot.virtualservice.extensions.responsetransformer.Base64DecodeBodyResponseTransformer',
    'com.trafficparrot.virtualservice.extensions.responsetransformer.HmacSigningTransformer',
    'com.example.UpperCaseBodyTransformer',
  ].join(','),
};

// The image tag is a hash of what the image is built from. testcontainers does not report a failed Docker
// build, it only checks that the tag exists afterwards, so under a fixed tag a failed rebuild would
// quietly run the previous image.
async function imageTag(imageDir) {
  const hash = crypto.createHash('sha256');
  for (const name of ['Dockerfile', 'trafficparrot.zip']) {
    for await (const chunk of fs.createReadStream(path.join(imageDir, name))) {
      hash.update(chunk);
    }
  }
  return `localhost/trafficparrot/cypress-suite:${hash.digest('hex').slice(0, 16)}`;
}

async function startTrafficParrot() {
  const trafficParrotDir = path.join(__dirname, 'trafficparrot');
  const imageDir = path.join(trafficParrotDir, 'image');
  const extensionsDir = path.join(trafficParrotDir, 'extensions');
  const jars = fs.readdirSync(extensionsDir)
    .filter((name) => name.endsWith('.jar'))
    .map((name) => ({ source: path.join(extensionsDir, name), target: `${INSTALL_DIRECTORY}/lib/external/${name}` }));

  // deleteOnExit: false keeps the image for the next run, so only the first run with a new zip pays for
  // apt-get and unzip. Each release zip leaves one image behind; remove old ones with `docker image rm`.
  // An image built from this Dockerfile can replace the build: `new GenericContainer('<that image>')`.
  // Any other image needs INSTALL_DIRECTORY set to its install directory, and withCommand replaced by
  // that image's own way of passing properties.
  const image = await GenericContainer
    .fromDockerfile(imageDir)
    .build(await imageTag(imageDir), { deleteOnExit: false });

  return image
    .withExposedPorts(MANAGEMENT_PORT, VIRTUAL_SERVICE_PORT)
    .withCopyFilesToContainer(jars)
    .withCopyDirectoriesToContainer([{ source: path.join(trafficParrotDir, 'traffic-files'), target: INSTALL_DIRECTORY }])
    // The image's own start command, with each property added as a key=value override.
    .withCommand([
      './start-foreground.sh',
      `trafficparrot.gui.http.port=${MANAGEMENT_PORT}`,
      `trafficparrot.virtualservice.http.port=${VIRTUAL_SERVICE_PORT}`,
      ...Object.entries(PROPERTIES).map(([key, value]) => `${key}=${value}`),
    ])
    // A license in TRAFFICPARROT_LICENSE (base64) takes precedence over the one in the zip.
    .withEnvironment(process.env.TRAFFICPARROT_LICENSE ? { TRAFFICPARROT_LICENSE: process.env.TRAFFICPARROT_LICENSE } : {})
    // The trailing slash matters: /__admin answers with a redirect, which this wait does not follow.
    .withWaitStrategy(Wait.forHttp('/__admin/', VIRTUAL_SERVICE_PORT).forStatusCode(200))
    .withStartupTimeout(180000)
    .start();
}

module.exports = defineConfig({
  e2e: {
    async setupNodeEvents(on, config) {
      const trafficParrot = await startTrafficParrot();
      // Stopped after the last spec of a `cypress run`. Ryuk removes it if the run dies, and after an
      // interactive `cypress open`, which does not fire after:run.
      on('after:run', () => trafficParrot.stop());

      const host = trafficParrot.getHost();
      console.log(`Traffic Parrot UI: http://${host}:${trafficParrot.getMappedPort(MANAGEMENT_PORT)}`);
      // Returned config is what every spec sees: Cypress.expose('trafficParrotUrl').
      config.expose = { ...config.expose, trafficParrotUrl: `http://${host}:${trafficParrot.getMappedPort(VIRTUAL_SERVICE_PORT)}` };
      return config;
    },
  },
});

The specs. A real spec drives your application, pointed at the same URL; these call Traffic Parrot directly to show the moving parts. cypress/e2e/orders.cy.js makes its own data and empties the request journal first, as the Java example above does:

describe('orders', () => {
  const trafficParrot = () => Cypress.expose('trafficParrotUrl');

  beforeEach(() => {
    cy.request('DELETE', `${trafficParrot()}/__admin/requests`);
  });

  it('serves the order for a key this test made up, and saw exactly one request for it', () => {
    const key = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
    cy.request(`${trafficParrot()}/orders/${key}`).its('body').should('equal', `order ${key}`);
    cy.request('POST', `${trafficParrot()}/__admin/requests/count`, { method: 'GET', url: `/orders/${key}` })
      .its('body.count').should('equal', 1);
  });
});

cypress/e2e/extension-and-database.cy.js, with the transformer jar and the H2 driver in trafficparrot/extensions and the database-connections.json and mappings of the Java example in trafficparrot/traffic-files:

describe('extension and database', () => {
  const trafficParrot = () => Cypress.expose('trafficParrotUrl');

  it('applies the transformer from the extension jar', () => {
    cy.request(`${trafficParrot()}/shout/hello`).its('body').should('equal', 'YOU SAID HELLO');
  });

  it('stores a row and reads it back through the dataSource helper', () => {
    const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
    cy.request({ method: 'POST', url: `${trafficParrot()}/records/${id}`, body: `name-${id}` }).its('status').should('equal', 201);
    cy.request(`${trafficParrot()}/records/${id}`).its('body').should('equal', `name-${id}`);
  });
});

Things to know:

  • The image is tagged with a hash of the Dockerfile and the zip, so each release zip leaves one image on the host, several hundred megabytes, which docker image rm removes; docker image prune does not take tagged images. The first run with a new zip builds it; a later run reuses it and has Traffic Parrot serving in a few seconds. Under a fixed tag a failed build would quietly run the previous image, because testcontainers checks only that the tag exists afterwards.
  • The wait is on /__admin/, with the trailing slash. /__admin answers with a redirect, which this wait does not follow, so without the slash the start times out although Traffic Parrot is serving.
  • after:run stops and removes the container at the end of cypress run. cypress open does not fire it; there, and when the run dies, Testcontainers' Ryuk container removes it.
  • An image built from this Dockerfile can replace the build step: new GenericContainer('<that image>'). Any other image needs INSTALL_DIRECTORY set to its install directory and withCommand replaced by that image's own way of passing properties.