DEV Community

Jihed Ben Arfa
Jihed Ben Arfa

Posted on

Testing Keycloak SPIs with Testcontainers — the part every tutorial skips

Most "here's how to write a custom Keycloak authenticator" posts end at mvn package. You get a class that implements Authenticator, a factory, a META-INF/services file, and a "drop the jar in providers/ and restart" instruction — and that's where the tutorial stops. Nobody shows you how to actually test the thing, which is a problem, because SPI development has a genuinely nasty feedback loop: package, copy the jar, restart Keycloak, click through a login flow by hand, repeat. I've spent enough evenings doing that manually on custom authenticators to want it gone.

The unit-testable part is easy. If your authenticator's logic is a pure function of some inputs (a user attribute, a client ID, a config value), you extract that into a method and mock the Keycloak model objects around it. That's normal JUnit + Mockito, nothing SPI-specific. The part that actually needs a real server is: does the jar deploy? Does META-INF/services point at the right class? Did you get a method signature wrong that only shows up as a stack trace on server startup? None of that shows up in a unit test, because a unit test never boots Keycloak.

Booting a real Keycloak in a test

Testcontainers makes this less painful than it sounds. You start the official image, copy your built jar straight into providers/ before the container starts, and let Keycloak boot normally:

@Container
private final GenericContainer<?> keycloak = new GenericContainer<>("quay.io/keycloak/keycloak:26.6")
        .withCopyFileToContainer(MountableFile.forHostPath(builtJarPath()), "/opt/keycloak/providers/my-spi.jar")
        .withEnv("KEYCLOAK_ADMIN", "admin")
        .withEnv("KEYCLOAK_ADMIN_PASSWORD", "admin")
        .withExposedPorts(8080)
        .withCommand("start-dev")
        .waitingFor(Wait.forHttp("/realms/master").forStatusCode(200));
Enter fullscreen mode Exit fullscreen mode

The wait strategy is the one detail that'll cost you an hour if you get it wrong. My first pass used Wait.forLogMessage(".*Running the server in development mode.*", 1) — that line prints during startup, but before the HTTP listener is actually accepting connections. The test would then immediately try to hit the admin API and get a connection reset, because the server said "I'm running" a couple hundred milliseconds before it actually was. Polling the real endpoint with Wait.forHttp(...) instead of trusting a log line fixed it — obvious in hindsight, and exactly the kind of thing you only learn by having the test flake on you in CI.

Once the container's up, you hit the admin REST API the same way an admin console would:

HttpRequest providersRequest = HttpRequest.newBuilder()
        .uri(URI.create(baseUrl + "/admin/realms/master/authentication/authenticator-providers"))
        .header("Authorization", "Bearer " + accessToken)
        .GET()
        .build();
Enter fullscreen mode Exit fullscreen mode

and assert your provider ID shows up in the list. That's it — that's the check that actually catches packaging mistakes: wrong META-INF/services entry, a factory that throws in init(), a missing dependency that only fails at classload time. None of that surfaces in a unit test.

Same idea, different provider type

I built a second provider — an event listener that publishes login events to Kafka — right after, and the same "don't mock the thing you're trying to prove works" logic applied to it, just with a different target. Mocking the KafkaProducer in a test would let a subtly wrong producer config (bad serializer, bad acks setting) pass silently. So instead of mocking, the integration test spins up a real Kafka broker via Testcontainers, constructs the provider with a real producer pointed at it, fires an event, and reads the message back with a real consumer. If the JSON shape is wrong, or the key/value serializers are misconfigured, the test catches it — a mock never would have.

The Maven wiring

One thing that trips people up: these integration tests need the packaged jar to already exist, so they can't run in the same phase as unit tests. Surefire runs unit tests at test (excluding anything named *IT.java), and Failsafe runs the integration tests at verify, after package has already produced the jar:

<plugin>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <excludes><exclude>**/*IT.java</exclude></excludes>
    </configuration>
</plugin>
<plugin>
    <artifactId>maven-failsafe-plugin</artifactId>
    <executions>
        <execution><goals><goal>integration-test</goal><goal>verify</goal></goals></execution>
    </executions>
</plugin>
Enter fullscreen mode Exit fullscreen mode

mvn test for the fast feedback loop while you're writing the logic, mvn verify when you actually need to know the thing deploys — which is also what CI runs, so a green build actually means something.

None of this is complicated once it's set up. It's just work nobody writes about, because "here's a class that implements an interface" is a much shorter blog post than "here's how you prove the class actually works on a real server." The second one's the only one that matters once you're shipping.

Top comments (0)