Co-authored-by: claude-code <claude-code@javagil.de>
Werkprobe
A work sample for Werkdock: a real Testcontainers program, written as any project would write it, run against werkdock instead of docker.
Werkdock is a sandbox CLI over bwrap that runs without root on a
Hostsharing managed webspace, and since 0.6.0 it serves the Docker
Engine API subset Testcontainers uses. Whether that is true is not a
matter of opinion, so this repository asks the question in the only
form that settles it: a PostgreSQLContainer, started, queried,
executed in, and stopped — and, the other way round, a Spring Boot
application whose database is nothing but a jdbc:tc URL in
application.yml.
Nothing here knows it is talking to werkdock. The whole adaptation is two environment variables:
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/werkdock.sock
export TESTCONTAINERS_RYUK_DISABLED=true
Running it
The Gradle wrapper is committed, so a JDK is all the build needs.
Against a werkdock on this machine:
werkdock pull postgres:16-alpine # once
werkdock system service &
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/werkdock.sock
export TESTCONTAINERS_RYUK_DISABLED=true
./gradlew test # both test classes
./gradlew bootRun # the Spring Boot application on its own
Against a webspace, which is the environment that has to work:
tools/deploy.sh mih09-werkdock@mih09.hostsharing.net
ssh mih09-werkdock@mih09.hostsharing.net '~/probe/run-probe.sh'
--spring runs the Spring Boot application instead of the plain
Probe; both are in the same directory, on one flat classpath.
deploy.sh builds one directory holding the program and every jar it
needs, copies it in a single ssh connection (the webspaces refuse
connections that follow too quickly), and leaves run-probe.sh beside
it. That script starts werkdock's socket, runs the probe, prints the
API traffic werkdock saw, and stops the socket again.
Against a real docker or podman, for comparison, set DOCKER_HOST at
that socket instead — the program does not change.
What is in here
| Path | |
|---|---|
src/test/java/werkprobe/PostgresContainerTest.java |
five JUnit tests, container code written out: query, published port, exec, recorded log, two databases side by side |
src/test/java/werkprobe/JdbcUrlDatabaseTest.java |
four JUnit tests, no container code at all: the database is the jdbc:tc URL from application.yml |
src/test/java/werkprobe/CopyFileToContainerTest.java |
the third way to a schema: a file copied into the container, run by the image's entrypoint (needs werkdock 0.7.0) |
src/test/java/werkprobe/RedisContainerTest.java |
not a database: PING over a plain socket, because werkdock's quirk table claimed a port redis was not listening on until 0.8.0 |
src/test/java/werkprobe/MariaDbContainerTest.java |
the same question for MariaDB, which asks the server itself with select @@port |
src/test/java/werkprobe/MySqlContainerTest.java |
and for MySQL |
src/test/java/werkprobe/NatsContainerTest.java |
NATS, greeted over a plain socket — it announces itself with INFO |
src/test/java/werkprobe/RabbitMqContainerTest.java |
RabbitMQ: a queue, a topic exchange, and the port cap its Erlang port forces |
src/test/java/werkprobe/MosquittoContainerTest.java |
MQTT, and the one server moved through a rewritten config file |
src/main/resources/application.yml |
where those URLs live, with what each part of them means |
src/main/java/werkprobe/WerkprobeApplication.java |
the Spring Boot application they configure |
src/main/java/werkprobe/Probe.java |
the same ground as a plain main, for a host with a JVM and no build tool |
tools/deploy.sh |
build, copy to a host, in one connection |
tools/run-probe.sh |
run it there, with the socket started and stopped around it |
The database as configuration
A jdbc:tc URL makes Testcontainers the JDBC driver: it reads the
image out of the URL, starts the container on the first connection, and
hands over to the real driver. No Java code mentions a container, so
this is the form most projects actually reach for.
spring:
datasource:
driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
url: jdbc:tc:postgresql:16-alpine:///werkprobe?TC_DAEMON=true&TC_INITSCRIPT=werkprobe-init.sql
TC_DAEMON=true matters here: without it Testcontainers stops the
container as soon as the last connection closes, and a connection pool
closes connections whenever it likes. TC_INITSCRIPT names a script on
the classpath that Testcontainers runs over JDBC — no file is
copied into the container, which is why it works on werkdock, whose API
has no PUT /containers/{id}/archive yet.
This path asked nothing new of werkdock: the same endpoints that serve the programmatic form serve this one. A second URL is a second server, on its own free port, the same way.
The third way is withCopyFileToContainer, which puts the script in
/docker-entrypoint-initdb.d and lets the image's entrypoint run it.
That one did ask something new — PUT /containers/{id}/archive,
which werkdock grew in 0.7.0 — and is the only test here that needs
that version.
The images werkdock claims to know
Werkdock cannot map ports — the sandbox shares the host's network — so for every image it publishes a port for, it has to move the server instead, and it keeps a table of how. Every entry in that table is a claim about someone else's image, and three of the four were wrong until werkdock 0.8.0: the server stayed where it was while werkdock reported a free port that nothing answered on.
Each is now a test here, which is the only place such a claim can be checked:
| Image | How werkdock moves it | Tested by |
|---|---|---|
postgres:16-alpine |
PGPORT |
three classes, the oldest tests here |
redis:7-alpine |
--port |
RedisContainerTest |
mariadb:11 |
--port= |
MariaDbContainerTest |
mysql:8 |
--port= |
MySqlContainerTest |
nats:2.10-alpine |
--port |
NatsContainerTest |
rabbitmq:4 |
RABBITMQ_NODE_PORT, capped |
RabbitMqContainerTest |
eclipse-mosquitto:2 |
config file, rewritten copy (werkdock 0.10.0) | MosquittoContainerTest |
Only postgres was right. Redis reads no REDIS_PORT, and neither
database reads a *_TCP_PORT; all three take a command-line flag. The
two SQL tests do not stop at connecting — they ask the server itself,
select @@port, because a JDBC URL that connects only proves something
answered, not that werkdock and the server agree.
The three messaging servers each added something the first four had not
asked for. NATS was clean. RabbitMQ derives its Erlang
distribution port by adding 20000 to the one it is given, so werkdock
must not choose above 45535 — and the kernel's ephemeral range, where it
used to draw from, starts at 32768. Mosquitto offers no port knob
at all: its shipped config declares its listeners, which switches off
the default one -p would set, and neither the environment nor the
config file expands anything. Werkdock 0.9.0 reported 1883 unmoved, and
MosquittoContainerTest asserted that, meant to fail the day werkdock
learned better. Werkdock 0.10.0 did: it copies the shipped config with
the listener lines rewritten and binds the copy over the original. The
test now asserts the opposite, for both listeners — MQTT and the
dashboard's HTTP API.
The RabbitMQ test covers both shapes a broker is used in, a queue and a topic exchange; the Mosquitto test covers an MQTT topic.
Each of the first four also found a second fault on the way: redis could not start at all
(its entrypoint chowns as root, which an unprivileged user namespace
cannot do), mariadb died without /run/mysqld (werkdock's /run is the
instance's own, so nothing of the image showed through), and
MariaDBContainer copies a config directory into /etc/mysql/conf.d,
which werkdock refused until 0.8.1.
What it proves, and what it does not
It passes when werkdock can pull an image, create and start a container, stream logs so a wait strategy sees them, publish a port a JDBC client can reach, run a command inside and report its exit code, and clean up afterwards.
Two instances at once work: werkdock picks a free port for each and tells each server to bind its own, so the two JDBC URLs address two independent databases. The test proves it by making a table through one URL and not finding it through the other.
Socket or IP
Both, on different levels. The client talks to werkdock over a unix
socket (DOCKER_HOST=unix://…, mode 0600, so only its owner reaches
it) — that is the control plane, the docker API. The database traffic
is TCP to localhost on the published port, because werkdock shares
the host's network and the server binds a real port there. Inside a
sandbox, werkdock exec … psql reaches the same server over the unix
socket the image keeps in /var/run/postgresql, which lives in the
instance's own directory.
One consequence worth knowing on a shared host: the official postgres
image listens on all interfaces, not only loopback, so the port is open
to whatever the machine's firewall allows. On the Hostsharing webspace
it was measured as refused from the outside, but that is the firewall's
doing, not werkdock's. Where it matters, bind loopback explicitly:
withCommand("postgres", "-c", "listen_addresses=127.0.0.1").
It says nothing about the features werkdock deliberately does not have: no networks, no port mapping, no build. The port test asserts only that the published port is not the container's own — under docker because the port is mapped, under werkdock because the server was moved to a free port and told about it. A test that assumes 5432 would be wrong on both.
The gradle wrapper is committed, so a JDK is all a build needs — any JDK from 17 up runs Gradle,
which builds with Temurin 25 and loads it with Foojay where it is missing (werkregie ADR 0001).
The bytecode targets Java 17, which is what the managed webspaces have, so tools/deploy.sh can send it to one.
The Testcontainers version comes from Spring Boot's BOM — 1.21.4 at
the time of writing. Werkdock's API subset was measured against 1.19.3
(werkdock docs/testcontainers-api.md), and both pass here, which says
the subset did not depend on that release. postgres:16-alpine is the
image everything was validated with; see werkdock's
docs/testcontainers.md for the setup.
The demo of werkcrappit's charts
werkcrappit measures Mutation-CRAP, cohesion and coupling of JVM code
and draws them as a CodeCharta code city, a structure page and a dependency matrix.
The package werkprobe.shop is a small shop written to show each of their colours once;
its flaws are on purpose.
./gradlew pitest mutationCrapCodeCharta
PIT mutates only the shop and runs only its unit tests, never the container tests.
The nightly build mutation on werkator.javagil.de does the same on main
and keeps build/reports/codecharta with index.html, structure.html and matrix.html.
| Where | What it shows |
|---|---|
service.OrderService.place |
Mutation-CRAP 4: complexity 4, every mutant killed — green |
service.Shipping.cost |
Mutation-CRAP 6: complexity 6, every mutant killed — yellow by complexity alone |
domain.Discounts.rate |
Mutation-CRAP 13.4: one weak test, most mutants survive — red |
domain.Catalog.reorderQuantity |
Mutation-CRAP 42: no test at all — red |
domain.Order |
LCOM4 2: its lines and its notes share nothing — yellow fill |
domain.Catalog |
LCOM4 3 and change risk 168: three groups, one untested — red |
domain.Order and domain.Invoice |
a class cycle — red frames |
service and persistence |
a package cycle across the hierarchy — red edges |
web and web.forms |
a package cycle along the hierarchy — amber edges |
The values are those of werkcrappit 0.26.0 on 2026-10-11; a later version or PIT may shift them a little.
build.gradle sets maxMutationCrap = 10 only for the colours:
red starts at 90 % of it, 9, so the classes to look at stand out before a gate would fail; yellow starts at half of that, 4.5.
No build runs the gate mutationCrapCheck, which fails on the red methods.