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:
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.
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:
Copy the workspace's repo/ directory next to your own pom.xml, add the repository entry, and depend on the module in test scope. The version is the directory name under repo/com/trafficparrot/trafficparrot-testcontainers/.
<repositories>
<repository>
<id>local-repository</id>
<url>file://${project.basedir}/repo</url>
<releases>
<checksumPolicy>ignore</checksumPolicy>
</releases>
</repository>
</repositories>
<dependency>
<groupId>com.trafficparrot</groupId>
<artifactId>trafficparrot-testcontainers</artifactId>
<version>X.Y.Z</version>
<scope>test</scope>
</dependency>
Testcontainers itself (org.testcontainers:testcontainers) comes with the module. What you add depends on the JUnit surface you use:
The workspace pom.xml declares all of these, so it is the reference for the versions that go together.
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.
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.
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
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.
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"));
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.
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. If any fixture came from a multi-mapping file (one JSON file holding several mappings) the reset fails instead, as an error from the admin API. |
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"
}
}
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();
}
}