Misk Testing: @MiskTest, Fakes, and Fast Integration Tests
Series: Building Production Services with Misk — Part 23 of 24
Most frameworks make testing an afterthought — you bolt on a test harness, mock the world, and pray the mocks resemble production. Misk does the opposite, and it’s the single most pleasant thing about working in it day to day. Misk testing is built on the same dependency injection that runs your service: @MiskTest stands up a real injector from a real module, fakes ship as first-class modules right next to their production counterparts, and the test gets its collaborators by @Inject. The payoff is that fast, high-fidelity tests are the default, not a thing you engineer. Let’s see why.
@MiskTest and the test module
A Misk test is a JUnit 5 class annotated @MiskTest. That annotation wires in an extension that, before each test method, builds a Guice injector from a module you supply and injects it into the test’s @Inject fields. Here’s the entire annotation — it’s tiny:
@Target(AnnotationTarget.CLASS)
@ExtendWith(LogLevelExtension::class)
@ExtendWith(MiskTestExtension::class)
annotation class MiskTest(val startService: Boolean = false)
@Target(AnnotationTarget.FIELD) annotation class MiskTestModule
The module comes from a field annotated @MiskTestModule. That’s it — the framework finds it, creates the injector, and populates your @Inject lateinit var fields. Here’s the exemplar’s HelloWebActionTest, verbatim:
@MiskTest
class HelloWebActionTest {
@MiskTestModule val module: Module = ExemplarTestModule()
@Inject private lateinit var helloWebAction: HelloWebAction
@Test
fun happyPath() {
assertThat(helloWebAction.hello("sandy", headersOf(), null, null))
.isEqualTo(HelloResponse("0000000000000000000000001", "SANDY"))
}
}
No WebAction HTTP plumbing here — it injects the action and calls the handler method directly, like a unit test, but the action’s real dependencies are wired by the real injector. The test module is small and readable:
class ExemplarTestModule : KAbstractModule() {
override fun configure() {
install(DeploymentModule(TESTING))
install(FakeClockModule())
install(FakeTokenGeneratorModule())
install(MiskTestingServiceModule())
}
}
Two things matter here. First, injection happens per test method: a fresh injector, fresh fields, every time. That isolation is why Misk tests don’t suffer the order-dependent flakiness that plagues shared-fixture suites. Second, @MiskTest defaults to startService = false. That distinction is the whole game: with services stopped, you’re testing units with real wiring; flip it to startService = true and Guava’s ServiceManager actually starts your services, web server included, and you’re testing integration. Same annotation, two modes.
That predictable hash in the expected response — "0000000000000000000000001" — isn’t a coincidence. It’s the fake token generator handing out deterministic IDs. Which brings us to the heart of the whole approach.
Fakes vs reals: the philosophy
Back in Part 2 I made a fuss about the production/test module split — the idea that a real and a fake binding for the same interface live side by side, and you choose which to install per environment. Testing is where that design earns its keep. Misk’s convention is that for every awkward real dependency, a fake ships as a module right next to it. You don’t write the fake; you install it.
Look again at ExemplarTestModule. FakeClockModule binds a Clock you can advance by hand, so time-dependent code is testable without Thread.sleep. FakeTokenGeneratorModule hands out the sequential tokens we just saw, so an assertion can name the exact ID it expects. MiskTestingServiceModule is the test-shaped sibling of the production service module. The pattern repeats across the ecosystem — FakeRedis from misk-redis, FakeAuditClient from misk-audit-client (Part 9), fake clients for damn near everything.
The reason this matters more than ordinary mocking: a fake is a real implementation of the interface, maintained by the same people who maintain the production one, exercised by Misk’s own test suite. A mock is a puppet whose behavior you assert into existence — and whose behavior silently drifts from reality the moment the real contract changes. FakeClock actually implements Clock. FakeRedis actually behaves like Redis for the operations it supports. When you test against fakes, you’re testing against something that can’t lie about the interface, because it implements it.
The practical upshot is that the control you’d normally fake with a mock instead comes from the fake’s own API. FakeClock doesn’t tick — you inject it and move it:
@Inject lateinit var clock: FakeClock
@Test
fun `token expires after an hour`() {
val token = service.issue()
clock.add(Duration.ofMinutes(61))
assertThat(service.isExpired(token)).isTrue()
}
Your production code sees a plain Clock; your test injects the concrete FakeClock and drives it. No Thread.sleep, no time mock that returns whatever you told it to regardless of how the code actually reads the clock — the code genuinely advances through the fake.
There’s a catch the framework now enforces. If you reuse an injector across tests (more on that shortly), stateful fakes — clocks, fake databases, fake queues — must be reset between tests or state leaks. Misk’s answer is the TestFixture interface: bind your fake, then multibind it as a fixture so the harness resets it.
bind<Clock>().to<FakeClock>()
multibind<TestFixture>().to<FakeClock>()
The exemplar takes this seriously enough to have a TestFixtureBindingValidator and a test (ExemplarTestFixtureValidationTest) whose only job is to fail loudly if a fake is bound without its TestFixture multibinding — exactly the kind of mistake that produces a 1-in-50 flake you’ll spend a sprint chasing.
Integration tests against the running server
Unit-calling an action is great, but sometimes you want the real thing: serialization, interceptor chain, routing, status codes — the whole request lifecycle terminating at HTTP. That’s startService = true plus a server module plus WebTestClient.
Install WebTestingModule in your test module — it pulls in WebServerTestingModule (which stands up Jetty on an ephemeral port) and MiskTestingServiceModule together — then inject WebTestClient and make real HTTP calls:
@MiskTest(startService = true)
class HelloWebIntegrationTest {
@MiskTestModule val module = MyModule()
@Inject lateinit var webTestClient: WebTestClient
@Test
fun `makes a call to the service`() {
val response = webTestClient.post("/hello", HelloRequest("world"))
assertThat(response.response.code).isEqualTo(200)
assertThat(response.parseJson<HelloResponse>()).isEqualTo(HelloResponse("hello world"))
}
}
WebTestClient is a thin OkHttp wrapper that knows the Jetty port — get, post, delete, JSON in and out via Moshi. The response exposes the real OkHttp Response (.response.code) and a typed parseJson<T>(). When you need to set a header or send a raw body (testing an auth header, a custom content type, a malformed payload), drop to the call { } escape hatch, which hands you OkHttp’s Request.Builder:
val response = webTestClient.call("/hello") {
header("Authorization", "Bearer $token")
post("""{"name":"world"}""".toRequestBody(APPLICATION_JSON_MEDIA_TYPE))
}
This is a real request over a real socket through your real interceptor stack (auth, logging, metrics, the lot), and because the server boots on an ephemeral port in-process, it still runs in milliseconds. Fast and honest, which is the recurring theme. The interceptors you can’t easily exercise from a unit-called action — the ones in Part 7 and Part 9 — are precisely the ones an integration test does cover.
If you need to reach below the convenience wrapper — say, a gRPC client pointed at the running app, or a custom WebConfig — install WebServerTestingModule directly and inject JettyService to read httpServerUrl. The exemplar’s ExemplarAuditClientTest does exactly this flavor of integration test, standing up a MockWebServer, installing MiskWebModule(WebConfig(0)), and asserting on recorded requests.
For database tests there’s a parallel story: install JdbcTestingModule (the misk-jdbc testing module) alongside your persistence module to clear and isolate the test datasource between runs. Note that the older HibernateTestingModule from misk-hibernate-testing still exists but is @Deprecated in favor of JdbcTestingModule — if you’re starting fresh, reach for the latter.
Testing the ActionScoped caller and verifying registration
Two Misk-specific testing problems deserve dedicated tools, and Misk ships both.
The first: actions read the authenticated caller via ActionScoped<MiskCaller> (Part 9). In a test there’s no auth proxy to populate it, so calling caller.get() blows up with “no action scope active.” The fix is @WithMiskCaller, which enters an action scope before each test with a caller you specify:
@MiskTest(startService = true)
@WithMiskCaller(user = "sandy")
class MyAuthenticatedActionTest {
@MiskTestModule val module = MyModule()
// ...
}
The annotation takes user or service (default: a "default-user"), and the docstring is explicit that you must place it after @MiskTest so the scope is entered inside the injector’s lifecycle. The exemplar’s ExemplarAuditClientTest uses it to give the audit path a caller to attribute events to.
The second problem is the unregistered-action bug from Part 5 — you write a WebAction, forget the matching install(WebActionModule.create<YourAction>()), and the endpoint silently doesn’t exist. No compile error, no startup error, just a 404 in production. WebActionRegistrationTester scans your packages for WebAction implementations, compares them against what’s actually registered in the injector, and fails with copy-paste-ready fix-up code:
@Test
fun allWebActionsAreRegistered() {
WebActionRegistrationTester.assertAllWebActionsRegistered(
injector,
WebActionRegistrationTester.Options(
basePackages = listOf("com.example.myservice"),
registrationModuleHint = "MyWebModule",
),
)
}
When something’s missing, the failure reads:
The following WebActions are not registered:
- com.example.dino.actions.ForgottenAction
Copy and paste the following lines into your WebAction registration module in DinoWebModule:
-----
install(WebActionModule.create<ForgottenAction>())
-----
Abstract classes, interfaces, and .api.internal. (Wire/gRPC-generated) actions are excluded automatically; an excludePredicate handles the rest. One test, and “I forgot to register it” stops being a class of production bug. This is the kind of thing you should add to every Misk service on day one.
Production notes & gotchas
startServiceis the load-bearing flag. Forget it and your integration test injects aWebTestClientagainst a server that never started — you’ll get connection-refused, not a helpful message. If a test talks HTTP, it needs@MiskTest(startService = true).- Fresh injector per method is the default, and it’s the safe one. It’s also the slow one on a fat dependency graph. The opt-in
MISK_TEST_REUSE_INJECTOR=truereuses injectors and service lifecycles across tests — real speedups, but it’s flagged experimental, the APIs “are likely to change,” and it only works if your@MiskTestModuleextendsReusableTestModule. Treat it as a tuning knob, not a default. - Reuse turns stateful fakes into a footgun. The moment you reuse injectors, an un-reset
FakeClockor fake DB leaks state into the next test. Bind every stateful fake as amultibind<TestFixture>()and consider stealing the exemplar’sTestFixtureBindingValidatortest so the omission fails loudly instead of flaking quietly. @MiskTestannotations only count on the outermost class. Misk supports JUnit@Nestedclasses, but@MiskTest,@MiskTestModule, and@MiskExternalDependencyon a nested class are ignored. Put them on the outer class; injection targets it too.- Order
@WithMiskCallerafter@MiskTest. The scope must be entered within the injector’s lifecycle. Reversed, the action scope isn’t available andcaller.get()fails. - There’s no broad mocking culture, and that’s the point. Misk gives you fakes, not Mockito; lean into them. If you find yourself wanting to mock a Misk component, check whether a
Fake*Modulealready exists — it usually does, and it’ll be more faithful than anything you’d stand up.
What’s next
We’ve got a service that’s built, secured, and thoroughly tested. The last piece is operating it — and Misk hands you an entire web console for free. In Part 24: The Misk Admin Dashboard we’ll tour the built-in admin dashboard: the tabs you get out of the box, how to add your own, and how the access annotations from Part 9 gate every one of them.
Target keywords: misk testing, @MiskTest.
Comments