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:
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:
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. 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. |
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();
}
}
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.
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:
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: