skip to content

Kotest's shouldBeInstanceOf<T>() and shouldNotBeNull() return a value rather than Unit. What do they return, how does that interact with Kotlin smart casts, and why does it matter for test readability?

level: seniorimportance: should knowfreq 28%

answer

  1. Matchers return narrowed value, not Unit
  2. Kotlin contracts drive the smart cast
  3. Replaces `as` (CCE) and `!!` (NPE)
  4. Smart cast needs a stable val — bind the return otherwise
  5. shouldBeNull has nothing to narrow

basics

~20 s

They return the value narrowed to the asserted type — T for shouldBeInstanceOf, the non-null T for shouldNotBeNull — and they carry Kotlin contracts so the original variable also smart-casts afterwards. That replaces as casts and !! with assertions that fail with real messages.

solid answer

~50 s

Both matchers are inline functions that return the narrowed value and declare a Kotlin contract, so after the call the compiler treats the original expression as narrowed too (for stable values such as local `val`s). ```kotlin val ok = result.shouldBeInstanceOf<Result.Ok>() ok.value shouldBe 42 // or chained result.shouldBeInstanceOf<Result.Ok>().value shouldBe 42 user.address.shouldNotBeNull().city shouldBe "Berlin" ``` Why it matters: the alternatives degrade failure diagnosis. `(result as Result.Ok).value` throws `ClassCastException` and `user.address!!.city` throws a bare `NullPointerException` — both read as a broken test, and neither says what was expected. The matcher produces a proper assertion failure naming expected and actual. Caveats: the smart cast applies only where Kotlin allows one (a `var` captured by a lambda, or a mutable property of another class, will not smart-cast) — so use the returned value, which always works. `shouldBeNull()` is the opposite assertion and returns nothing useful.

code

kotlin · 12 lines
kotlin
sealed interface Response
data class Success(val body: String) : Response
data class Failure(val code: Int) : Response

val r: Response = call()

// narrowed return, chained
r.shouldBeInstanceOf<Failure>().code shouldBe 404

// contract also smart-casts the original val
r.shouldBeInstanceOf<Failure>()
r.code shouldBe 404

go deeper

for a junior

Know that these matchers hand back the narrowed value so you can keep asserting without a cast.

for a middle

Explain the contract-based smart cast and show the chained form on a sealed hierarchy.

for a senior

Lead with failure diagnosis — assertion errors instead of CCE/NPE — and name the stable-expression limit on smart casts.

for a principal

Position it as a suite-wide convention: banning !! and as in tests in favour of narrowing matchers keeps CI output diagnosable and makes expectations explicit.

## The mechanism Several Kotest matchers are more than assertions: they are *narrowing* operations. - `shouldBeInstanceOf<T>()` returns the receiver typed as `T`. - `shouldBeTypeOf<T>()` does the same for the exact-type check. - `shouldNotBeNull()` returns the receiver with the nullable type stripped. They are `inline` functions with `reified` type parameters where needed, and they declare Kotlin **contracts** — a compile-time promise of the form "if this function returns normally, the receiver is a `T`" or "... is not null". The compiler consumes that promise and applies a smart cast to the original expression for the remainder of the scope. So you get two mechanisms at once: a returned narrowed value you can chain on, and a smart cast on the original variable. In practice the returned value is the one to rely on, because smart casts only apply to stable expressions. ## Why this beats casts and !! Consider the same test three ways. ```kotlin // 1. unsafe cast (handle(cmd) as Result.Ok).value shouldBe 42 // failure: java.lang.ClassCastException: Result$Error cannot be cast to Result$Ok // 2. not-null assertion user.address!!.city shouldBe "Berlin" // failure: java.lang.NullPointerException // 3. matchers handle(cmd).shouldBeInstanceOf<Result.Ok>().value shouldBe 42 user.address.shouldNotBeNull().city shouldBe "Berlin" // failure: a Kotest assertion error naming expected type / actual value ``` The first two throw *exceptions from the language runtime*. To a reader scanning CI output, an NPE or a CCE looks like the test itself is broken. The third throws an assertion error whose message states the expectation and what was actually found — the same category of output as every other failing matcher in the suite, and it points at the real defect immediately. There is a second, quieter benefit: the matcher form documents intent. `shouldNotBeNull()` says "this is part of the specification"; `!!` says "I know something the compiler doesn't". ## Chaining patterns The narrowed return makes assertions read as a pipeline: ```kotlin val event = publisher.published() .shouldHaveSize(1) // note: collection matchers also return the receiver .first() .shouldBeInstanceOf<OrderPlaced>() event.orderId shouldBe orderId ``` Combined with `assertSoftly` or `withClue` you keep the narrowing while improving the report — but the narrowing itself is what removes the casts. A very common shape in sealed-hierarchy tests: ```kotlin sealed interface Response data class Success(val body: String) : Response data class Failure(val code: Int) : Response val r: Response = call() r.shouldBeInstanceOf<Failure>().code shouldBe 404 ``` Without the matcher you would write a `when` with an `else -> fail(...)` branch, which is four lines of ceremony for one expectation. ## The limits of the smart cast Kotlin only smart-casts *stable* expressions. These do not smart-cast, whatever contract is in play: - a `var` that is captured and potentially modified by a lambda; - a mutable `var` property, especially one declared in another module; - a property with a custom getter, since two reads can return different values; - an open `val` that a subclass could override with a custom getter. In those cases the assertion still runs and still fails correctly — you simply do not get the free narrowing on the original name. The fix is always the same: bind the returned value. ```kotlin // no smart cast on a mutable property, but the return still narrows val addr = holder.address.shouldNotBeNull() addr.city shouldBe "Berlin" ``` ## shouldBeNull, the mirror image `shouldBeNull()` asserts the value *is* null. There is nothing to narrow to, so there is nothing useful to chain. Note also that `x shouldBe null` is a legal alternative; prefer the dedicated matcher for symmetry with `shouldNotBeNull()` and for a clearer message. ## What an interviewer is listening for 1. "Returns the narrowed value" — the mechanical answer. 2. "Kotlin contracts" — the reason the *original* variable also narrows. 3. "Better failure messages than CCE/NPE" — the reason it matters in practice. 4. "Smart casts need stable values, so bind the return" — the caveat that shows you have actually hit the edge case.

  • Why prefer shouldNotBeNull() over `!!` in a test?
    Because `!!` throws a bare NullPointerException, which reads as a broken test rather than a stated expectation and carries no message about what was expected. `shouldNotBeNull()` produces a normal assertion failure, documents that non-nullity is part of the specification, and hands back the non-null value so the rest of the chain type-checks.
  • You call shouldBeInstanceOf<Foo>() on a mutable property and the compiler still complains about the type afterwards. Why?
    Kotlin only applies smart casts to stable expressions. A `var` property — particularly one from another class or module, or one with a custom getter — could change between reads, so the compiler refuses the narrowing even though the contract fired. Assign the matcher's return value to a local `val` and use that.

saying these in an interview costs you the question

  • Believing the matchers return Unit and reaching for `as` casts afterwards
  • Claiming the smart cast works on any expression, including mutable properties
  • Treating an NPE from `!!` as an acceptable test failure message
  • Thinking shouldBeNull() also returns something useful to chain on
  • Saying the narrowing is a runtime trick rather than a compile-time contract

context