/

Testing

Depend on KarboneClient

Code 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"}"""))
}

FakeKarbone

FakeKarbone 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:

Example test

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 tests

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.

Blocking code under test

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>()