JUnit 6 ships with assertEquals, assertTrue, and a handful of other primitives. They work, but they leave a lot to be desired the moment a test fails. You get a message like expected: <true> but was: <false>, with no context about what was actually wrong. In a large Spring Boot project with dozens of test classes, that kind of noise slows everyone down.
AssertJ and Kotest Assertions both give you fluent, human-readable assertions with failure messages that tell the full story. They come from different design philosophies, and the one that fits your team depends on whether you're working in a pure Kotlin codebase, a mixed Java/Kotlin project, or something in between.
This article compares AssertJ and Kotest Assertions on syntax and readability, IntelliJ IDEA support, custom assertion strategies, soft assertions, Spring Boot integration, and adoption. The goal is a practical answer, not a feature matrix.
How this comparison works
Each section covers one dimension of the decision, starting with language design and syntax, then IDE support, custom assertions and soft assertions, Spring Boot integration, and a final recommendation. Code examples use real domain objects you'd find in a Spring Boot service, including a User entity, a REST response DTO, and a repository query result.
Both libraries are treated with equal seriousness. AssertJ has a decade-long track record as the default assertion library in the Spring Boot starter. Kotest Assertions brings a Kotlin-native DSL that aligns with how idiomatic Kotlin reads and works. Each fits a specific context, and that's what this article maps out.
Language design, syntax, and readability
AssertJ was designed for Java. Its API is a fluent chain of method calls, so assertThat(user.getName()).isEqualTo("Alice").startsWith("A") translates cleanly to Kotlin with no changes required. Kotlin adds things Java can't express, though, and AssertJ's API doesn't take advantage of them.
Kotest Assertions was built specifically for Kotlin. It uses infix functions and extension functions to produce assertions that read like natural language:
// AssertJ
assertThat(user.name).isEqualTo("Alice")
assertThat(user.email).isNotNull().endsWith("@example.com")
// Kotest
user.name shouldBe "Alice"
user.email.shouldNotBeNull() shouldEndWith "@example.com"
The difference becomes more pronounced when null safety comes into play. AssertJ runs on top of the JVM and doesn't know whether your value is nullable. To assert on a nullable field, you call .isNotNull() first, then continue chaining. Kotest's shouldNotBeNull() asserts the value is non-null and returns it as a non-null-typed result, so you can keep chaining matchers directly off the return value, without an extra null check of your own.
Exception assertions follow the same pattern. AssertJ uses assertThatThrownBy, which wraps the code under test in a lambda. For Kotest, shouldThrow does the job:
// AssertJ
assertThatThrownBy { userService.findById(-1L) }
.isInstanceOf(NotFoundException::class.java)
.hasMessageContaining("User not found")
// Kotest
val exception = shouldThrow<NotFoundException> {
userService.findById(-1L)
}
exception.message shouldContain "User not found"
Kotest returns the thrown exception as a typed value, which means you can assert on its specific fields with full type information. AssertJ gives you an AbstractThrowableAssert, and while you can chain off it, you lose the concrete type unless you cast (or reach for assertThatExceptionOfType/catchThrowableOfType, which do return the concrete type if you need it).
For collection assertions, both libraries are capable, but their ergonomics differ. AssertJ uses extracting and containsExactly to navigate through collections, while Kotest delegates to standard Kotlin operations and then applies matchers:
val users = listOf(
User(id = 1L, role = "ADMIN"),
User(id = 2L, role = "USER")
)
// AssertJ
assertThat(users)
.hasSize(2)
.extracting("role")
.containsExactly("ADMIN", "USER")
// Kotest
users shouldHaveSize 2
users.map { it.role } shouldContainExactly listOf("ADMIN", "USER")
The Kotest version reads like code you'd write anyway. The AssertJ version is clear, but extracting("role") uses a string property name, which loses type safety and won't be caught by the compiler if you rename the field. (AssertJ also has a type-safe overload, extracting(User::getRole), that avoids this specific problem if you prefer a method reference over a string.)
Use AssertJ when you need a single assertion style that works uniformly across Java and Kotlin sources. Kotest Assertions earns its place when your codebase is Kotlin-first and you want assertions that feel native to the language.
IDE support and developer experience
Both libraries work well inside IntelliJ IDEA, but their discoverability stories differ.
AssertJ's API is a method chain off assertThat(...). Once you type that call, IntelliJ's autocomplete presents the full list of available matchers for the specific type you passed in. Type a dot and browse the options. Static imports for Assertions.assertThat are handled by IntelliJ's import completion. IntelliJ's "Click to see difference" diff viewer opens side-by-side on assertion failure, and the IDE's general type-mismatch inspections apply to AssertJ calls the same way they apply to any other Java/Kotlin method chain — if you're not sure how far IntelliJ's AssertJ-specific inspections go in your version, it's worth checking the current release notes rather than assuming a specific check exists.
Kotest's extension functions change the discovery model. Because Kotest matchers are Kotlin extension functions defined on specific types, IntelliJ surfaces them through the same mechanism that surfaces any extension function, giving you autocomplete on the object itself. Type user.name and then a space, and IntelliJ will suggest shouldBe, shouldNotBe, and every other matcher that applies to String. The matchers are tied to the type system, so you won't see a string matcher when you're asserting on a number.
For teams new to Kotest, this has a practical effect: developers discover matchers organically by typing on their object and reading what appears. There's less need to memorize the API surface or rely on static import completion, because the type system does some of the teaching. That said, Kotest's API surface is still large, and a team new to the library will spend real time learning which matcher names exist in the first place, especially for the less common ones (shouldBeEqualIgnoringScale, shouldContainExactlyInAnyOrder, and similar) — autocomplete narrows the list, but it doesn't substitute for reading the matcher catalog once.
The Kotest project also publishes an IntelliJ IDEA plugin, distributed through the JetBrains Marketplace. Install it, and you get gutter icons next to individual test blocks, a dedicated Kotest tool window for browsing test hierarchies, and IntelliJ's diff viewer wired directly to Kotest assertion failures. For BehaviorSpec and DescribeSpec styles, the plugin renders the nested Given/When/Then or Describe/It hierarchy in the Structure tool window, which makes navigating a large test class much faster.
Navigation and refactoring behave the same way they do for any other Kotlin or Java code: go-to-declaration on a Kotest matcher takes you to its extension-function source, and renaming a property is a standard Kotlin rename-refactor regardless of which assertion library references it afterward. AssertJ's assert classes are ordinary Java types, so navigation into them (and documentation popups showing their Javadoc) works the same way it does for any other library on your classpath. Kotest's matchers carry KDoc, so hovering a matcher name shows the same kind of documentation popup you'd get from any Kotlin extension function.
AssertJ's experience is more static-import-dependent. On a new machine or a fresh project, getting assertThat to resolve without a red squiggly requires the import to be present. Teams used to this workflow find it natural, but teams migrating from JUnit's built-in assertions sometimes find the static import pattern mildly tedious until they configure IntelliJ's "add unambiguous imports on the fly" setting. That's the main developer frustration on the AssertJ side; on the Kotest side, the more common frustration is the opposite problem — so many matchers exist across so many modules that it can take a project or two before a team has internalized the vocabulary.
For daily use in a Kotlin project, Kotest's extension function-based discovery and the dedicated plugin make it the more ergonomic option inside IntelliJ IDEA. For mixed Java/Kotlin codebases where both languages write tests, AssertJ's consistent method-chain API across both languages is a real advantage.
Custom assertions and soft assertions
Custom assertions
Every meaningful Spring Boot project reaches the point where you're writing the same group of assertions in multiple test classes. A User entity has a handful of fields you always check together. A REST response DTO carries a status, a message, and a payload that need coordinated verification. A validation result needs its error list checked as a group. The right answer is a reusable custom assertion.
With AssertJ, you have two patterns. The first uses Kotlin extension functions on ObjectAssert<T>, which keeps your custom matchers chainable without subclassing:
fun ObjectAssert<User>.isActiveAdmin(): ObjectAssert<User> = satisfies(Consumer { user: User ->
assertThat(user.status).isEqualTo(Status.ACTIVE)
assertThat(user.role).isEqualTo("ADMIN")
})
// In tests
assertThat(user).isNotNull.isActiveAdmin()
Note the explicit Consumer { ... } wrapper around the lambda. AssertJ's satisfies method has three overloads — one taking a Condition, and two taking varargs of Consumer or ThrowingConsumer — and Kotlin can't infer which functional interface a bare trailing lambda should become when two of those overloads have an identical shape. Wrapping the lambda in Consumer { ... } resolves the ambiguity.
The second pattern extends AbstractAssert directly for complex domain objects where you want a dedicated assertion class with its own failure messages:
class UserAssert(actual: User?) : AbstractAssert<UserAssert, User>(actual, UserAssert::class.java) {
fun hasValidEmail(): UserAssert {
isNotNull()
if (actual.email?.contains("@") != true) {
failWithMessage("Expected valid email but was <%s>", actual.email)
}
return this
}
}
fun assertThatUser(actual: User): UserAssert = UserAssert(actual)
// In tests
assertThatUser(user).hasValidEmail()
Two details matter here. First, actual.email is nullable, so the check needs a safe call (actual.email?.contains("@") != true) rather than a direct .contains(...) call. Second, name your factory function something other than assertThat. If you call it assertThat(actual: User): UserAssert — which is the more common way this pattern is written — it will silently lose to org.assertj.core.api.Assertions.assertThat the moment both are in scope in the same file, which is any file that also uses plain AssertJ assertions elsewhere. Kotlin's import-scope rules rank an explicitly imported name above a same-named function declared in your own package, so assertThat(user) resolves to the generic AssertJ overload every time, and your custom method never gets called — it just fails with "unresolved reference" at the call site, with nothing wrong-looking in the custom class itself. Naming the factory assertThatUser (or putting it behind an aliased import) avoids the collision entirely.
Kotest handles custom assertions through extension functions on the subject itself, which fits naturally with how Kotlin is written:
fun User.shouldBeActiveAdmin() {
this.status shouldBe Status.ACTIVE
this.role shouldBe "ADMIN"
}
// In tests
user.shouldBeActiveAdmin()
Kotest also supports Matcher<T>, a composable interface that lets you build matchers from other matchers and give them reusable names. This scales well for assertion libraries shared across multiple modules in a large project.
The Kotest approach produces less boilerplate and doesn't require you to understand the AbstractAssert class hierarchy or its overload quirks. For a team writing Kotlin, extension functions on the subject type are the pattern they already know, and reusing them in tests feels consistent with the rest of the codebase.
A validation result is a good example of a domain object that benefits from this treatment, since you're usually checking a boolean flag and a list of messages together:
data class ValidationResult(val valid: Boolean, val errors: List<String>)
// AssertJ
assertThat(result.valid).isFalse()
assertThat(result.errors)
.hasSize(2)
.contains("email must not be blank", "age must be positive")
// Kotest
result.valid shouldBe false
result.errors shouldContainExactlyInAnyOrder listOf(
"email must not be blank",
"age must be positive"
)
Wrapping either version in a hasValidationErrors(vararg messages: String)-style custom assertion (an AbstractAssert subclass for AssertJ, an extension function for Kotest) removes the repetition once this check shows up in more than one test class — the same tradeoff as the User examples above.
Soft assertions
Both libraries support soft assertions (Kotest's assertSoftly included), which collect all failures in a block instead of stopping at the first one. When you're asserting ten fields on a response object, reporting all failures at once saves debugging cycles.
// AssertJ
SoftAssertions.assertSoftly { softly ->
softly.assertThat(order.status).isEqualTo(OrderStatus.CONFIRMED)
softly.assertThat(order.totalPrice).isEqualByComparingTo(BigDecimal("99.99"))
softly.assertThat(order.items).hasSize(3)
}
// Kotest
assertSoftly(order) {
status shouldBe OrderStatus.CONFIRMED
totalPrice.shouldBeEqualIgnoringScale(BigDecimal("99.99"))
items shouldHaveSize 3
}
Kotest's assertSoftly block scopes the receiver, so you reference status, items, and totalPrice directly without repeating softly.assertThat(order.x) on every line. The reduction in noise is real in test classes that verify complex aggregates like an Order with nested line items or a validation result with multiple error messages. shouldBeEqualIgnoringScale is the Kotest equivalent of AssertJ's isEqualByComparingTo here — it compares the numeric value and ignores trailing-zero scale differences, so BigDecimal("99.99") and BigDecimal("99.990") are treated as equal. AssertJ's assertSoftly requires the softly prefix on every assertion line, which is more verbose but also more explicit about which assertion library you're calling.
Spring Boot integration
AssertJ ships inside spring-boot-starter-test. Add the Spring Boot test starter and AssertJ is available with no additional configuration:
dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
Kotest Assertions requires an additional dependency. If you want Kotest's full test framework (its FunSpec, BehaviorSpec, and other styles), you also add the JUnit Platform runner. For teams that want only Kotest's assertion DSL inside JUnit 6 tests, kotest-assertions-core is enough. If you're also mocking with MockK inside Spring test slices (as in the controller example below), add springmockk for its @MockkBean annotation:
dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
// Optional: full Kotest framework
testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
// Needed for @MockkBean in Spring test slices
testImplementation("com.ninja-squad:springmockk:4.0.2")
}
Both libraries integrate with the full Spring Boot test stack. A @WebMvcTest controller test using MockK for mocking and Kotest for assertions looks like this:
@WebMvcTest(UserController::class)
class UserControllerTest(@Autowired private val mockMvc: MockMvc) {
@MockkBean
private lateinit var userService: UserService
@Test
fun `should return user by id`() {
val user = User(id = 42L, email = "alex@example.com", status = Status.ACTIVE)
every { userService.findById(42L) } returns user
val response = mockMvc.get("/api/users/42") {
accept = MediaType.APPLICATION_JSON
}.andExpect {
status { isOk() }
}.andReturn().response.contentAsString
val dto = jacksonObjectMapper().readValue<UserResponseDto>(response)
dto.email shouldBe "alex@example.com"
dto.status shouldBe "ACTIVE"
}
}
For a thinner service-layer test — no Spring context, no web layer, just the business logic — the same syntax from the opening sections applies directly, with no annotations at all:
class UserServiceTest {
private val userService: UserService = InMemoryUserService()
// AssertJ
@Test
fun `assertj service test`() {
assertThat(userService.findById(1L).status).isEqualTo(Status.ACTIVE)
}
// Kotest
@Test
fun `kotest service test`() {
userService.findById(1L).status shouldBe Status.ACTIVE
}
}
For repository tests with @DataJpaTest, the AssertJ version uses satisfies to group assertions on a single result.
The following is an example of a Java test next to its Kotlin counterpart — in a mixed codebase, AssertJ's API doesn't change shape between the two:
// Java
@DataJpaTest
class UserRepositoryJavaTest {
@Autowired
private UserRepository repo;
@Autowired
private TestEntityManager em;
@Test
void shouldFindActiveUsersByDomain() {
repo.save(new User(0L, "Alice", "alice@company.com", "USER", Status.ACTIVE));
repo.save(new User(0L, "Bob", "bob@company.com", "USER", Status.INACTIVE));
em.flush();
List<User> results = repo.findActiveByDomain("company.com");
assertThat(results).hasSize(1);
assertThat(results.get(0).getEmail()).isEqualTo("alice@company.com");
assertThat(results.get(0).getStatus()).isEqualTo(Status.ACTIVE);
}
}
// Kotlin
@DataJpaTest
class UserRepositoryTest(
@Autowired private val repo: UserRepository,
@Autowired private val em: TestEntityManager
) {
@Test
fun `should find active users by domain`() {
repo.save(User(email = "alice@company.com", status = Status.ACTIVE))
repo.save(User(email = "bob@company.com", status = Status.INACTIVE))
em.flush()
val results = repo.findActiveByDomain("company.com")
// AssertJ
assertThat(results).hasSize(1).first().satisfies(Consumer { u ->
assertThat(u.email).isEqualTo("alice@company.com")
assertThat(u.status).isEqualTo(Status.ACTIVE)
})
// Kotest equivalent
results shouldHaveSize 1
with(results.single()) {
email shouldBe "alice@company.com"
status shouldBe Status.ACTIVE
}
}
}
As with the custom-assertion example earlier, .satisfies(...) needs an explicit Consumer { ... } wrapper in Kotlin — a bare trailing lambda doesn't compile against AssertJ's vararg overloads.
If your repository tests run against a real database via Testcontainers instead of an embedded one, none of the assertion syntax above changes — assertThat(...) and shouldBe/shouldHaveSize work the same way against a Testcontainers-backed PostgreSQLContainer as they do against H2, since they're asserting on the List<User> the repository returns, not on anything container-specific. The assertion library and the integration-test infrastructure are orthogonal choices.
When using the full Kotest test framework alongside Spring Boot, you register SpringExtension to enable context loading and constructor-based dependency injection inside Kotest specs:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserServiceIntegrationTest(
private val restTemplate: TestRestTemplate
) : FunSpec({
extension(SpringExtension)
test("should fetch user profile") {
val response = restTemplate.getForEntity("/api/users/1", String::class.java)
response.statusCode shouldBe HttpStatus.OK
response.body.shouldNotBeNull()
}
})
Notice the constructor parameter has no @Autowired annotation. Kotest's SpringExtension resolves constructor dependencies itself, independently of Spring's own TestConstructor autowire mode (which, under plain JUnit, defaults to requiring @Autowired per parameter or a global property, as covered in our companion piece on JUnit, Testcontainers, MockK, Kotest, and Spring Boot Test). Inside a Kotest spec specifically, it just works.
For teams already using Mockito in Java test sources, AssertJ slots in without friction. For teams that have adopted MockK (the idiomatic Kotlin mocking library), pairing it with Kotest Assertions produces a consistent, Kotlin-native test stack.
One real-world consideration worth noting: Kotest's native coroutine support applies to the full Kotest test framework, not to Kotest Assertions by itself. Inside a Kotest spec (FunSpec, BehaviorSpec, and the rest), test bodies are themselves suspend lambdas run by the Kotest engine, so you can call suspend functions directly with no wrapper. If you're using only kotest-assertions-core inside an ordinary JUnit @Test method, that method is not a suspend function, and calling a suspend function from it fails to compile — you still need runBlocking or runTest around the call, exactly as you would with any other assertion library.
Which assertion library to choose
There's no universally correct answer, but the decision tree is short.
Choose AssertJ when your team maintains both Java and Kotlin test sources and wants a single assertion style across both. AssertJ is already in spring-boot-starter-test, so the cost of adoption is zero. Its method-chain API is widely understood by Java developers and requires no onboarding. Teams that value a minimal dependency footprint and standard tooling will find AssertJ the path of least resistance.
Choose Kotest Assertions when your project is Kotlin-first and you want assertions that align with idiomatic Kotlin. The infix DSL reduces noise in test output and in code review. The assertSoftly scoping removes softly.assertThat(...) repetition. Null safety is handled through return-type narrowing rather than manual checks. If you're already using the Kotest test framework for its spec styles, this is the obvious choice. You can also adopt Kotest Assertions inside JUnit 6 tests without migrating to the full framework, which makes it a low-risk addition to an existing Spring Boot project.
On performance and adoption: neither library adds meaningful runtime overhead to a test suite. Both are thin DSL layers over ordinary JVM assertion failures — the cost is a method call or two per check, not a separate execution engine (the one exception is adopting the full Kotest test framework as your test runner, which does replace JUnit's default test discovery and execution, though not in a way that changes wall-clock test time in any way that matters day to day). On adoption, AssertJ has the longer track record simply by virtue of having shipped as the Spring Boot default for about a decade; Kotest's adoption has grown alongside Kotlin's own growth on the backend, particularly among teams already using Kotest's spec styles or MockK. Neither is a niche choice at this point — the decision is about fit with your codebase's language, not about betting on an unproven library.
Mixing assertion libraries is viable within limits. Using Kotest Assertions inside JUnit 6 tests alongside AssertJ for specific cases (such as Condition-based matchers already established in the codebase) works without conflict. Mixing the full Kotest test framework with JUnit 6 test classes in the same module requires more care in configuration but is supported. Mixing becomes a liability when new team members can't tell which style to follow when writing a new test; in that case, the team is better served by picking one library and standardizing.
Migrating from AssertJ to Kotest Assertions in a Kotlin codebase follows an incremental path: start with new test classes, use shouldBe and assertSoftly there, and leave existing AssertJ tests in place. The two styles don't interfere, and the migration pressure comes naturally as old tests get refactored.
Choosing based on language gravity
AssertJ and Kotest Assertions both make test failures informative and test code readable, but they reflect different assumptions about who's writing the tests and what the language can do. AssertJ's Java-first fluent API surprises no JVM developer and integrates at zero cost via the Spring Boot starter. Kotest's Kotlin-native DSL aligns with how Kotlin code reads and works, particularly around null safety and extension function discoverability in IntelliJ IDEA — though its native coroutine-testing story is a feature of the full Kotest spec framework, not of Kotest Assertions on its own.
For Java developers working in mixed codebases and evaluating whether to bring Kotest into their Kotlin test sources, you can adopt Kotest Assertions incrementally, one test class at a time, without disturbing what's already there. The kotest-assertions-core dependency is the only addition required, and it coexists cleanly with AssertJ and JUnit 6. The official JetBrains–Spring partnership continues to improve Kotlin's position as a first-class language for Spring development, and Kotest is part of a mature Kotlin testing ecosystem that includes MockK, kotlin.test, and full Spring Boot test integration. For a concrete account of Kotlin running mission-critical backend systems in production, see this KotlinConf recap of Verifone's work on Kotlin in payments infrastructure.
Pick the library that matches your codebase's language gravity. If your tests are trending Kotlin, Kotest Assertions will feel like a natural fit.
Top comments (0)