KarboneClientCode that calls Karbone should depend on the KarboneClient interface (templates, renders, status()), not on the concrete Karbone class. Karbone is the real, network-backed implementation; com.ekino.oss.karbone.testing.FakeKarbone is an in-memory double implementing the same interface, so a service can be tested without any HTTP or Docker involved.
class InvoiceService(private val karbone: KarboneClient) {
context(_: Raise<KarboneError>)
suspend fun renderInvoice(id: TemplateId, number: String): RenderedDocument =
karbone.renders.render(TemplateSource.id(id), RenderData.raw("""{"number":"$number"}"""))
}FakeKarboneFakeKarbone stores templates in memory (keyed by the same SHA-256 Carbone would use), so the hash-first render flow works exactly as with the real client.
val fake = FakeKarbone()
// pre-load a template so a TemplateSource.Remote id resolves
val id = fake.addTemplate(templateBytes)
val document = either { fake.renders.render(TemplateSource.id(id), RenderData.raw("{}")) }
.getOrElse { error("render failed") }Key surface:
addTemplate(content, options) — pre-loads a template, returning its (SHA-256) TemplateId, so renders and downloads against a known id work without going through upload.calls — every call made on the fake, in order, as a sealed Call value (one variant per KarboneClient method — see the API reference for the full list). Useful to assert what a service under test actually did.rendered — every RenderedDocument produced so far, in order.failNextWith(error) — the next call, whatever it is, raises error instead of running. Use it to test a single failure path without standing up a whole failing scenario.renderer — a FakeKarbone.Renderer (a fun interface) producing the bytes of a rendered document from a RenderRequest (templateId, template bytes, data, options). The default renderer writes a small JSON description of the request; swap it for a custom one to assert on realistic-looking output, or to simulate a specific format.reset() — clears stored templates, pending async renders, recorded calls, rendered documents and any pending failNextWith error.status — a mutable ApiStatus returned by status(); defaults to a successful fake status.class InvoiceServiceSpec :
ShouldSpec({
should("render an invoice and record the call") {
val fake = FakeKarbone()
val id = fake.addTemplate("template bytes".toByteArray())
val service = InvoiceService(fake)
val document = either { service.renderInvoice(id, "F-1") }.shouldBeInstanceOf<Either.Right<RenderedDocument>>().value
document.fileName shouldNotBe null
fake.calls.filterIsInstance<FakeKarbone.Call.Render>() shouldHaveSize 1
}
should("propagate a template-not-found error") {
val fake = FakeKarbone()
fake.failNextWith(KarboneError.TemplateNotFound(TemplateId("missing"), "gone"))
val service = InvoiceService(fake)
val result = either { service.renderInvoice(TemplateId("missing"), "F-1") }
result.shouldBeInstanceOf<Either.Left<KarboneError>>().value.shouldBeInstanceOf<KarboneError.TemplateNotFound>()
}
})A plain JUnit 5 test reads the same way, just without Kotest’s ShouldSpec DSL — FakeKarbone and Arrow’s either/Either are ordinary Kotlin, no test-framework dependency involved.
Karbone’s own integration tests (CarboneContainerSpec, CarboneAuthContainerSpec) run against a real carbone-ee container via Testcontainers, covering both authentication-disabled and JWT-authenticated setups. They are skipped automatically when Docker is not available, and are a good reference for exact request/response shapes if you need to go beyond what FakeKarbone simulates.
Code written against KarboneBlocking is tested the same way: the facade is built over KarboneClient, so a fake slips in.
val fake = FakeKarbone()
val service = InvoiceService(fake.blocking()) // or KarboneBlocking.of(fake) from Java
service.generate(invoice)
fake.calls.filterIsInstance<FakeKarbone.Call.Render>() shouldHaveSize 1
fake.failNextWith(KarboneError.Unauthorized("expired"))
shouldThrow<KarboneException> { service.generate(invoice) }.error.shouldBeInstanceOf<KarboneError.Unauthorized>()