<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Aniket Bhattacharyea</title>
    <description>The latest articles on DEV Community by Aniket Bhattacharyea (@heraldofsolace).</description>
    <link>https://dev.to/heraldofsolace</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F27791%2F94532916-fa4c-4972-a381-f219844eff22.png</url>
      <title>DEV Community: Aniket Bhattacharyea</title>
      <link>https://dev.to/heraldofsolace</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/heraldofsolace"/>
    <language>en</language>
    <item>
      <title>Spring Boot Integration Tests Are Slow and Messy: Here's How to Fix Them</title>
      <dc:creator>Aniket Bhattacharyea</dc:creator>
      <pubDate>Wed, 21 Oct 2026 16:49:16 +0000</pubDate>
      <link>https://dev.to/heraldofsolace/spring-boot-integration-tests-are-slow-and-messy-heres-how-to-fix-them-4dnm</link>
      <guid>https://dev.to/heraldofsolace/spring-boot-integration-tests-are-slow-and-messy-heres-how-to-fix-them-4dnm</guid>
      <description>&lt;p&gt;Integration test suites can get slow without anyone deciding they should. Another &lt;code&gt;@SpringBootTest&lt;/code&gt; here, some in-memory H2 wiring there, a flaky test "fixed" with a stray &lt;code&gt;@DirtiesContext&lt;/code&gt; –  and eventually the suite is the thing everyone quietly dreads before merging a PR, flaky on every other CI run, with nobody able to point to the one change that caused it.&lt;/p&gt;

&lt;p&gt;Spring Boot integration tests do real work, validating behavior across controllers, services, repositories, databases, and messaging systems. That breadth is what makes them valuable, and it's also what makes them expensive. When you wire together a real application context, connect to databases, and exercise multiple layers in a single test run, costs add up fast. The question is whether those costs are unavoidable or whether your test suite pays for things it doesn't need.&lt;/p&gt;

&lt;p&gt;This article walks through the specific mechanisms that make Spring Boot integration suites slow and brittle, and the practical strategies that address them. You'll also see how Kotlin's extension functions, DSLs, data classes, named arguments, and scope functions reduce the boilerplate that accumulates in integration test code over time, making the resulting suite easier to maintain regardless of the language you're already using.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Spring Boot integration tests slow down over time
&lt;/h2&gt;

&lt;p&gt;Most suites start with a single &lt;code&gt;@SpringBootTest&lt;/code&gt; class and a handful of tests. That first test class is harmless. The problems compound when a second developer adds another &lt;code&gt;@SpringBootTest&lt;/code&gt; with a slightly different configuration, a third adds &lt;code&gt;@ActiveProfiles("integration")&lt;/code&gt; to one class and &lt;code&gt;@ActiveProfiles("test")&lt;/code&gt; to another, and a fourth drops a &lt;code&gt;@DirtiesContext&lt;/code&gt; on a test that was flaky. Six months later, the suite boots five or six separate application contexts across a single run, and nobody realizes it.&lt;/p&gt;

&lt;p&gt;Before you optimize anything, profile the actual bottleneck. Spring Boot's context initialization is usually the dominant cost, but database container startup and heavyweight test setup can be significant too. Run your tests with &lt;code&gt;-Dspring.test.context.cache.maxSize=10&lt;/code&gt; alongside JUnit's &lt;code&gt;--report-dir&lt;/code&gt; output to see how many contexts are being created. If you see more than two or three distinct contexts in a moderate-sized suite, context proliferation is your problem.&lt;/p&gt;

&lt;p&gt;Nearly every fix in this article reduces to a single idea: repeated initialization is the thing you're paying for, not the tests themselves. Whether that initialization is a Spring application context, a database container, or heavyweight test data, the fastest fix is almost always to do it once and reuse it rather than make each instance cheaper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the number of Spring application contexts small
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@SpringBootTest&lt;/code&gt; loads your full application context, all beans, all configuration, all auto-configuration. For a non-trivial Spring Boot application, that initialization can take several seconds. The Spring TestContext Framework caches loaded contexts and reuses them across compatible tests, so the first test in a group pays the startup cost and subsequent tests don't.&lt;/p&gt;

&lt;p&gt;The cache key is a composite of everything that affects the context, including configuration classes, active profiles, property sources, and context customizers. Change any of these between two test classes and Spring boots a second context. Common cache-busting patterns include using different &lt;code&gt;@ActiveProfiles&lt;/code&gt; values across test classes, importing different &lt;code&gt;@TestConfiguration&lt;/code&gt; classes with incompatible beans, and overusing &lt;code&gt;@DirtiesContext&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@DirtiesContext&lt;/code&gt; signals that a test has modified the application context and that Spring should discard it. Using it broadly because a single test is flaky trades a flakiness problem for a performance problem. The test that genuinely modifies shared mutable state may warrant it, but the test that just needs a clean database table doesn't.&lt;/p&gt;

&lt;p&gt;The practical target is a single shared context for your full integration tests, achieved by keeping configuration consistent. A shared base class or a single shared &lt;code&gt;@TestConfiguration&lt;/code&gt; import applied consistently across test classes is the most reliable way to ensure the cache key matches. In Kotlin, the recommended approach combines constructor injection with &lt;code&gt;@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)&lt;/code&gt;, which avoids &lt;code&gt;lateinit var&lt;/code&gt;s and keeps test fields immutable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@SpringBootTest&lt;/span&gt;
&lt;span class="nd"&gt;@TestConstructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autowireMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TestConstructor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AutowireMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ALL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderServiceIntegrationTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;orderService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;orderRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderRepository&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`placing&lt;/span&gt; &lt;span class="n"&gt;an&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="n"&gt;persists&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="nf"&gt;database`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;productId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"p-42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;saved&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isPresent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Kotlin &lt;code&gt;plugin.spring&lt;/code&gt; compiler plugin automatically opens Spring-annotated classes, resolving the friction Kotlin's default final classes would otherwise cause with Spring's proxying, so no additional configuration is needed for this to work correctly.&lt;/p&gt;

&lt;p&gt;None of this means avoiding &lt;code&gt;@SpringBootTest&lt;/code&gt;. A full application context is the right tool whenever a test genuinely needs to exercise behavior that spans multiple layers, for example, an HTTP request that flows through a controller, a service, and a repository. The goal isn't eliminating full-context tests; it's making sure you're not accidentally paying the full-context startup cost two, three, or five times over in a single suite when one shared context would do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test slices selectively, not as a default performance fix
&lt;/h2&gt;

&lt;p&gt;Spring Boot's test slices (&lt;code&gt;@WebMvcTest&lt;/code&gt;, &lt;code&gt;@DataJpaTest&lt;/code&gt;, &lt;code&gt;@JdbcTest&lt;/code&gt;, &lt;code&gt;@JsonTest&lt;/code&gt;, &lt;code&gt;@RestClientTest&lt;/code&gt;) load only the portion of your application relevant to a specific concern. A &lt;code&gt;@DataJpaTest&lt;/code&gt; context contains JPA repositories and the JPA layer, nothing else. A &lt;code&gt;@WebMvcTest&lt;/code&gt; context contains MVC infrastructure without starting a full servlet container.&lt;/p&gt;

&lt;p&gt;Slices are useful when a test genuinely exercises only one layer. A repository test that validates a complex JPQL query doesn't need your HTTP layer. A controller test that validates request validation logic doesn't need your database. For those cases, a slice gives you a faster, more focused test.&lt;/p&gt;

&lt;p&gt;The trap is treating slices as a general performance fix. Each distinct slice configuration that differs from another creates its own application context. A suite with ten &lt;code&gt;@DataJpaTest&lt;/code&gt; classes using the same configuration shares one context. A suite with five different slice types, each loaded with different imports, creates five separate contexts, and the cumulative startup cost can exceed that of a single, well-shared full context.&lt;/p&gt;

&lt;p&gt;Use a slice when it provides a meaningful testing boundary for the layer under test. For tests that validate behavior spanning multiple layers, prioritize reusing a small number of shared full contexts over reaching for a narrower slice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use Testcontainers without repeatedly starting containers
&lt;/h2&gt;

&lt;p&gt;H2 is a convenient in-memory database for local testing, but it doesn't behave like PostgreSQL or MySQL in ways that affect production correctness. PostgreSQL enforces stricter type checking, so implicit casts that H2 accepts silently will fail at runtime against Postgres. Indexing semantics differ, and constraint enforcement, particularly around deferred constraints and transaction isolation, behaves differently. A green test suite against H2 can mask bugs that surface only under the production database engine.&lt;/p&gt;

&lt;p&gt;Testcontainers solves this by running your actual database engine in a Docker container during tests. The startup cost is real (a PostgreSQL container cold-start typically takes a few seconds), but you pay it once per suite, not once per test class.&lt;/p&gt;

&lt;p&gt;With Spring Boot 3.1+, the &lt;code&gt;@ServiceConnection&lt;/code&gt; annotation eliminates the boilerplate of manually wiring container ports into Spring's datasource configuration, and it's unchanged under Spring Boot 4. Declare the container in a shared &lt;code&gt;@TestConfiguration&lt;/code&gt; class and import it wherever needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@TestConfiguration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proxyBeanMethods&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TestcontainersConfiguration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="nd"&gt;@ServiceConnection&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;postgresContainer&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;PostgreSQLContainer&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;*&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="nc"&gt;PostgreSQLContainer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"postgres:17-alpine"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;code&gt;@DataJpaTest&lt;/code&gt; slices, Spring attempts to replace your datasource with an embedded database by default. Suppress that behavior with &lt;code&gt;@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)&lt;/code&gt; and import your shared container configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@DataJpaTest&lt;/span&gt;
&lt;span class="nd"&gt;@AutoConfigureTestDatabase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;replace&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AutoConfigureTestDatabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Replace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NONE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TestcontainersConfiguration&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BookmarkRepositoryTest&lt;/span&gt; &lt;span class="nd"&gt;@Autowired&lt;/span&gt; &lt;span class="k"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;bookmarkRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;BookmarkRepository&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`should&lt;/span&gt; &lt;span class="n"&gt;find&lt;/span&gt; &lt;span class="n"&gt;bookmark&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="nf"&gt;id`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;bookmark&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bookmarkRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;Bookmark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Kotlin Docs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://kotlinlang.org"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;found&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bookmarkRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bookmark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;isPresent&lt;/span&gt;
        &lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isEqualTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Kotlin Docs"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two dependency details are worth getting right here if you're on Spring Boot 4. First, &lt;code&gt;@DataJpaTest&lt;/code&gt; itself now lives in &lt;code&gt;spring-boot-data-jpa-test&lt;/code&gt;, pulled in via the &lt;code&gt;spring-boot-starter-data-jpa-test&lt;/code&gt; starter, not the plain &lt;code&gt;spring-boot-starter-test&lt;/code&gt;. Second, &lt;code&gt;@AutoConfigureTestDatabase&lt;/code&gt; moved too: it's now &lt;code&gt;org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase&lt;/code&gt;, shipped in a separate &lt;code&gt;spring-boot-starter-jdbc-test&lt;/code&gt; starter that &lt;code&gt;spring-boot-starter-data-jpa-test&lt;/code&gt; doesn't pull in automatically, so you need to add it explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;testImplementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.springframework.boot:spring-boot-starter-data-jpa-test"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;testImplementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.springframework.boot:spring-boot-starter-jdbc-test"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;testImplementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.springframework.boot:spring-boot-testcontainers"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;testImplementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.testcontainers:junit-jupiter"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;testImplementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.testcontainers:postgresql"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@ServiceConnection&lt;/code&gt; itself, by contrast, didn't move; it's still &lt;code&gt;org.springframework.boot.testcontainers.service.connection.ServiceConnection&lt;/code&gt; on Spring Boot 4. Not everything in the test-support space was reshuffled, just the test-slice annotations and the autoconfiguration classes that back them.&lt;/p&gt;

&lt;p&gt;If you prefer declaring the container directly in the test class, Kotlin's companion objects with &lt;code&gt;@JvmStatic&lt;/code&gt; ensure the container lifecycle spans all methods in the class rather than restarting for each test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@DataJpaTest&lt;/span&gt;
&lt;span class="nd"&gt;@AutoConfigureTestDatabase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;replace&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AutoConfigureTestDatabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Replace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NONE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Testcontainers&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderRepositoryTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;companion&lt;/span&gt; &lt;span class="k"&gt;object&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;@Container&lt;/span&gt;
        &lt;span class="nd"&gt;@ServiceConnection&lt;/span&gt;
        &lt;span class="nd"&gt;@JvmStatic&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;postgres&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PostgreSQLContainer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"postgres:17-alpine"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;withDatabaseName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"testdb"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Autowired&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;lateinit&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;orderRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderRepository&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`should&lt;/span&gt; &lt;span class="n"&gt;save&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="n"&gt;retrieve&lt;/span&gt; &lt;span class="n"&gt;an&lt;/span&gt; &lt;span class="nf"&gt;order`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// test body&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The goal is to combine a reused container with a reused Spring context. When your test configuration is consistent, Spring's cache keeps the same context alive across all test classes that share the same container configuration, and the database container itself stays running for the duration of the build.&lt;/p&gt;

&lt;p&gt;For database state, build only what each test actually needs. A test that validates order retrieval doesn't need a populated product catalog. Lightweight, targeted setup with &lt;code&gt;@BeforeEach&lt;/code&gt; and &lt;code&gt;@AfterEach&lt;/code&gt; cleanup (or transaction rollback via &lt;code&gt;@Transactional&lt;/code&gt;) keeps tests isolated without requiring full database resets between every run.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical strategy for a fast integration test suite
&lt;/h2&gt;

&lt;p&gt;The context sharing model below keeps a suite fast by mapping each test type to its context and container lifecycle:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The diagram below is the editable Mermaid source — useful if you want to adjust colors or labels. A rendered image follows it.&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dot"&gt;&lt;code&gt;&lt;span class="nv"&gt;flowchart&lt;/span&gt; &lt;span class="nv"&gt;TD&lt;/span&gt;
    &lt;span class="nv"&gt;A&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Full&lt;/span&gt; &lt;span class="nv"&gt;Integration&lt;/span&gt; &lt;span class="nv"&gt;Tests&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;br/&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nv"&gt;SpringBootTest&lt;/span&gt; &lt;span class="err"&gt;+&lt;/span&gt; &lt;span class="nv"&gt;Testcontainers&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;B&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Single&lt;/span&gt; &lt;span class="nv"&gt;shared&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;br/&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;Spring&lt;/span&gt; &lt;span class="nv"&gt;context&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
    &lt;span class="nv"&gt;C&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Slice&lt;/span&gt; &lt;span class="nv"&gt;Tests&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;br/&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nv"&gt;DataJpaTest&lt;/span&gt; &lt;span class="nv"&gt;etc&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;D&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Shared&lt;/span&gt; &lt;span class="nv"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;br/&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;context&lt;/span&gt; &lt;span class="nv"&gt;per&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
    &lt;span class="nv"&gt;E&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Unit&lt;/span&gt; &lt;span class="nv"&gt;Tests&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;F&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;No&lt;/span&gt; &lt;span class="nv"&gt;Spring&lt;/span&gt; &lt;span class="nv"&gt;context&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
    &lt;span class="nv"&gt;B&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;G&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Postgres&lt;/span&gt; &lt;span class="nv"&gt;container&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;br/&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt;started&lt;/span&gt; &lt;span class="nv"&gt;once&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
    &lt;span class="nv"&gt;D&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;G&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The strategy has seven parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Keep one &lt;code&gt;@TestConfiguration&lt;/code&gt; class that declares your Testcontainers beans with &lt;code&gt;@ServiceConnection&lt;/code&gt; and import it consistently across all tests that need it.&lt;/li&gt;
&lt;li&gt;Align profiles. If some test classes use &lt;code&gt;@ActiveProfiles("test")&lt;/code&gt; and others don't declare a profile at all, Spring treats them as different configurations and creates separate contexts.&lt;/li&gt;
&lt;li&gt;Reserve &lt;code&gt;@DirtiesContext&lt;/code&gt; for tests that genuinely modify application-level state, not for tests with database state issues. Database state cleanup belongs in &lt;code&gt;@AfterEach&lt;/code&gt; or via transactional rollback.&lt;/li&gt;
&lt;li&gt;Reach for a test slice only when the test's meaningful scope is genuinely limited to that layer. A repository test for a complex query is a good slice candidate. A test that exercises an HTTP endpoint through to the database is not.&lt;/li&gt;
&lt;li&gt;Start containers once and reuse them. Use &lt;code&gt;withReuse(true)&lt;/code&gt; on your container definition to persist containers across JVM runs during local development, where it cuts iteration time substantially.&lt;/li&gt;
&lt;li&gt;Build only the database state each test actually needs. Seeding a full product catalog for a test that only checks order retrieval adds setup cost without adding coverage.&lt;/li&gt;
&lt;li&gt;Measure before and after changes. Run the test suite twice and enable DEBUG-level logging on &lt;code&gt;org.springframework.test.context.cache&lt;/code&gt; (or equivalent context cache reporting) to confirm that context creation counts dropped and wall-clock time improved.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Make integration tests readable with Kotlin
&lt;/h2&gt;

&lt;p&gt;Kotlin reduces the boilerplate that accumulates around integration tests, meaning less noise when a test fails and less maintenance when fixtures change. The techniques below don't make a suite faster on their own; they make an already-fast suite easier to read and maintain, which is what keeps it fast six months from now instead of sliding back into the state this article opened with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data classes&lt;/strong&gt; eliminate the Java builder pattern for test fixtures. A &lt;code&gt;Bookmark&lt;/code&gt; data class with sensible defaults can be constructed with a single named argument for the one field a specific test cares about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;BookmarkFixture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Default Title"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://kotlinlang.org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Named arguments&lt;/strong&gt; make test data construction self-documenting. &lt;code&gt;BookmarkFixture(title = "Archived Post", isActive = false)&lt;/code&gt; is readable without a builder chain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Extension functions&lt;/strong&gt; let you add setup utilities or assertion helpers directly to Spring test classes without inheritance hierarchies. A setup helper for building a JSON &lt;code&gt;POST&lt;/code&gt; request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nc"&gt;MockMvc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ObjectMapper&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;ResultActionsDsl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;contentType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;APPLICATION_JSON&lt;/span&gt;
        &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeValueAsString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;accept&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;APPLICATION_JSON&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're on Spring Boot 4, the &lt;code&gt;ObjectMapper&lt;/code&gt; import above is worth double-checking. Spring Boot 4 defaults to &lt;strong&gt;Jackson 3&lt;/strong&gt;, pulled in through a new &lt;code&gt;spring-boot-starter-jackson&lt;/code&gt; module, and Jackson 3's base package moved from &lt;code&gt;com.fasterxml.jackson&lt;/code&gt; to &lt;code&gt;tools.jackson&lt;/code&gt;, so the type you inject here is &lt;code&gt;tools.jackson.databind.ObjectMapper&lt;/code&gt;, not the Jackson 2 class of the same simple name. If you're posting a Kotlin data class as the request body, also add &lt;code&gt;tools.jackson.module:jackson-module-kotlin&lt;/code&gt; (the Jackson 3 equivalent of the familiar &lt;code&gt;jackson-module-kotlin&lt;/code&gt;) as a dependency. Without it, Jackson can't resolve the data class's primary-constructor parameters, and the request fails with a 400 instead of a compile error, making it easy to lose an afternoon to.&lt;/p&gt;

&lt;p&gt;And an assertion helper, scoped to the type it asserts on rather than to a test class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nc"&gt;Bookmark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertIsActiveWithTitle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;assertTrue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedTitle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Kotlin DSLs&lt;/strong&gt; built on extension functions and lambdas-with-receivers are what make the &lt;code&gt;postJson&lt;/code&gt; helper above readable in the first place: &lt;code&gt;post(url) { contentType = ...; content = ... }&lt;/code&gt; reads as configuration, not as a chain of setter calls. Spring's own MockMvc Kotlin DSL (&lt;code&gt;post&lt;/code&gt;, &lt;code&gt;get&lt;/code&gt;, &lt;code&gt;.andExpect { status { isCreated() } }&lt;/code&gt;) follows the same pattern, and it's worth reaching for directly rather than only through a wrapping extension function when a test doesn't need one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope functions&lt;/strong&gt; (&lt;code&gt;apply&lt;/code&gt;, &lt;code&gt;also&lt;/code&gt;, &lt;code&gt;run&lt;/code&gt;) clean up construction and setup code at the declaration site without extracting helper methods. &lt;code&gt;apply&lt;/code&gt; is the one you'll reach for most, configuring an object and returning it, as in the companion-object container declaration from the Testcontainers section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@JvmStatic&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;postgres&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PostgreSQLContainer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"postgres:17-alpine"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;withDatabaseName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"testdb"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;withUsername&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;withPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;also&lt;/code&gt; is useful when you want to do something with a value, like logging it, without changing what gets returned, and &lt;code&gt;run&lt;/code&gt; is useful for computing a value inline from a short block instead of a separate &lt;code&gt;val&lt;/code&gt; statement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;fixture&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BookmarkFixture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Kotlin Docs"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;also&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"creating bookmark fixture: ${it.title}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;BookmarkRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isActive&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put together, a small integration test combining a fixture, the &lt;code&gt;postJson&lt;/code&gt; extension function, and a scope function reads as a short, flat sequence of steps rather than a wall of setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@SpringBootTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webEnvironment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SpringBootTest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WebEnvironment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MOCK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@AutoConfigureMockMvc&lt;/span&gt;
&lt;span class="nd"&gt;@Import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TestcontainersConfiguration&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@TestConstructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autowireMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TestConstructor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AutowireMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ALL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BookmarkControllerIntegrationTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;mockMvc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;MockMvc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;objectMapper&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ObjectMapper&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`creating&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;bookmark&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="nf"&gt;response`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;fixture&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BookmarkFixture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Kotlin Docs"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;also&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"creating bookmark fixture: ${it.title}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;BookmarkRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isActive&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;mockMvc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/bookmarks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;andExpect&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;isCreated&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that &lt;code&gt;@AutoConfigureMockMvc&lt;/code&gt; moved packages in Spring Boot 4 too, same as &lt;code&gt;@WebMvcTest&lt;/code&gt;: it's &lt;code&gt;org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc&lt;/code&gt; now, shipped in &lt;code&gt;spring-boot-webmvc-test&lt;/code&gt; via the &lt;code&gt;spring-boot-starter-webmvc-test&lt;/code&gt; starter.&lt;/p&gt;

&lt;p&gt;These are incremental improvements, not architectural changes. You can introduce Kotlin test code into an existing Java Spring project without touching production code, which makes it a low-risk starting point for teams evaluating what Kotlin brings to their codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Organize integration tests for long-term maintainability
&lt;/h2&gt;

&lt;p&gt;A suite that's fast today becomes slow again if there's no shared understanding of its structure. Keep three distinct categories separate: unit tests (no Spring context, run fast), slice tests (partial context, targeted), and full integration tests (full context, shared container). Mixing these in the same test source set makes it difficult to see at a glance why a build is slow. Consistent naming helps here too; a suffix convention like &lt;code&gt;*Test&lt;/code&gt; for units and slices versus &lt;code&gt;*IntegrationTest&lt;/code&gt; for full-context tests, or separate source directories, lets you tell at a glance (and in CI timing reports) which category is growing.&lt;/p&gt;

&lt;p&gt;Integration tests should assert on business behavior, not implementation details. A test that verifies the correct HTTP status code and response body when a user places an order is durable. A test that verifies the exact SQL statement executed against the database is brittle and couples the test to an implementation that can change without breaking the business requirement.&lt;/p&gt;

&lt;p&gt;Avoid duplicating setup logic across test classes just because it's easier than sharing it. A second &lt;code&gt;@TestConfiguration&lt;/code&gt; import that's nearly identical to an existing one is exactly the kind of inconsistency that busts the context cache described earlier, so the maintainability concern and the performance concern point to the same fix.&lt;/p&gt;

&lt;p&gt;Shared base classes are worth using when they genuinely simplify the suite, typically for consistent annotation setup and shared infrastructure, and worth avoiding when they become a dumping ground for setup logic that only some tests need. A shared annotations file (using Kotlin's &lt;code&gt;@Target(AnnotationTarget.CLASS)&lt;/code&gt; composed annotations) is often cleaner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Target&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AnnotationTarget&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CLASS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Retention&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AnnotationRetention&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RUNTIME&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@SpringBootTest&lt;/span&gt;
&lt;span class="nd"&gt;@Import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TestcontainersConfiguration&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@TestConstructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autowireMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TestConstructor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AutowireMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ALL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;annotation&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;IntegrationTest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any &lt;code&gt;@SpringBootTest&lt;/code&gt; class that uses &lt;code&gt;@IntegrationTest&lt;/code&gt; instead gets a consistent cache key by definition, because the annotation composition is identical.&lt;/p&gt;

&lt;p&gt;Treat test suite performance as an ongoing metric. Add context creation count and total suite duration to your CI output so regressions are visible before they compound. A suite that takes four minutes today will take twenty-two again in six months if nobody is watching the cost.&lt;/p&gt;

&lt;p&gt;The fixes that move the needle on Spring Boot integration test performance share a common thread: reducing repeated initialization. Keep your Spring application contexts few and consistent so the cache does its job. Start Testcontainers infrastructure once and keep it alive across the test run. Use test slices when they define a genuine testing boundary, and avoid them when the goal is just a smaller context. Measure before and after so you know which bottleneck you actually solved.&lt;/p&gt;

&lt;p&gt;Kotlin's language features sit naturally on top of this foundation. Data classes, extension functions, DSLs, named arguments, and scope functions cut boilerplate without changing how Spring tests work, making them a practical addition to a Java Spring project even before you change any production code. The formal partnership between JetBrains and Spring reflects this kind of incremental integration, with Kotlin as a natural evolution of the JVM development experience that complements your existing ecosystem. For a sense of what that looks like at scale, Verifone's architect discussed running Kotlin in mission-critical payment systems at &lt;a href="https://blog.jetbrains.com/kotlin/2026/10/kotlinconf-recap-lessons-from-verifone-s-architect-on-kotlin-in-mission-critical-payments/" rel="noopener noreferrer"&gt;KotlinConf&lt;/a&gt;, where reliability requirements are just as unforgiving as anything a slow test suite is standing in for.&lt;/p&gt;

</description>
      <category>springboot</category>
      <category>testing</category>
      <category>kotlin</category>
      <category>java</category>
    </item>
    <item>
      <title>JUnit 6 Parameterized Tests: The Complete Guide to Data-Driven Testing on the JVM</title>
      <dc:creator>Aniket Bhattacharyea</dc:creator>
      <pubDate>Wed, 21 Oct 2026 16:44:55 +0000</pubDate>
      <link>https://dev.to/heraldofsolace/junit-6-parameterized-tests-the-complete-guide-to-data-driven-testing-on-the-jvm-28on</link>
      <guid>https://dev.to/heraldofsolace/junit-6-parameterized-tests-the-complete-guide-to-data-driven-testing-on-the-jvm-28on</guid>
      <description>&lt;p&gt;Writing the same test five times with different inputs is copying, not testing. Parameterized testing breaks that habit by letting you write a single assertion once and run it against as many datasets as the problem demands. &lt;a href="https://docs.junit.org/6.1.1/user-guide/" rel="noopener noreferrer"&gt;JUnit 6&lt;/a&gt; (the Jupiter framework) gives you a rich set of argument sources, clean display names, and enough flexibility to handle everything from a handful of strings to deeply nested domain objects.&lt;/p&gt;

&lt;p&gt;For developers building Spring Boot services, getting good at parameterized testing pays compound interest. Service layer logic, validation rules, pricing calculations, and repository queries all share the same structure: given some input, expect some output. Parameterized tests make that pattern explicit and give you a test suite that grows with your domain model.&lt;/p&gt;

&lt;p&gt;Most developers run into friction with complex inputs: DTOs, entity objects, configuration records, and the boilerplate required to construct them in Java.&lt;/p&gt;

&lt;p&gt;This guide walks through every major parameter source in JUnit 6, shows when and why to reach for each one, covers the specific patterns that surface in Spring Boot test suites across every layer (service, controller, repository, and configuration), and then demonstrates how Kotlin's data classes and named arguments cut test setup code without touching a single line of production Java.&lt;/p&gt;

&lt;h2&gt;
  
  
  What parameterized tests are
&lt;/h2&gt;

&lt;p&gt;A parameterized test is a test method that JUnit runs multiple times, injecting a different set of arguments on each invocation. You write the assertion logic once, and the framework handles the loop. Each invocation is tracked, named, and reported independently in the test runner, so a failure on input three doesn't mask a pass on input five.&lt;/p&gt;

&lt;p&gt;The key annotation is &lt;a href="https://docs.junit.org/6.1.1/writing-tests/parameterized-classes-and-tests.html" rel="noopener noreferrer"&gt;&lt;code&gt;@ParameterizedTest&lt;/code&gt;&lt;/a&gt;, which replaces &lt;code&gt;@Test&lt;/code&gt;. You pair it with a source annotation that tells JUnit where to pull argument values from. JUnit 6 ships with sources for inline values, CSV rows, CSV files, factory methods, and pluggable providers, each suited to a different complexity tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup for parameterized tests
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Required dependencies
&lt;/h3&gt;

&lt;p&gt;If you're starting from a Spring Boot project, &lt;code&gt;spring-boot-starter-test&lt;/code&gt; already pulls in the JUnit Jupiter engine. You need to add &lt;code&gt;junit-jupiter-params&lt;/code&gt; explicitly if it's not coming in transitively from your BOM.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.junit.org/6.0.0/release-notes.html" rel="noopener noreferrer"&gt;JUnit 6 raises the minimum Java version&lt;/a&gt; to &lt;strong&gt;17&lt;/strong&gt; (JUnit 5 only required 8), and if you're writing tests in Kotlin, you'll need &lt;strong&gt;Kotlin 2.2 or newer&lt;/strong&gt;. Spring Boot 4.x already baselines on Java 17 and manages &lt;a href="https://docs.spring.io/spring-boot/appendix/dependency-versions/coordinates.html" rel="noopener noreferrer"&gt;compatible JUnit and Kotlin versions&lt;/a&gt; for you, so the simplest path is to build on Spring Boot 4.&lt;/p&gt;

&lt;p&gt;Gradle (Kotlin DSL):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;plugins&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.springframework.boot"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="s"&gt;"4.1.1"&lt;/span&gt;
    &lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"io.spring.dependency-management"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="s"&gt;"1.1.7"&lt;/span&gt;
    &lt;span class="nf"&gt;kotlin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"jvm"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="s"&gt;"2.3.21"&lt;/span&gt;
    &lt;span class="nf"&gt;kotlin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"plugin.spring"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="s"&gt;"2.3.21"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.springframework.boot:spring-boot-starter-web"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.jetbrains.kotlin:kotlin-reflect"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;testImplementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.springframework.boot:spring-boot-starter-test"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;testImplementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.junit.jupiter:junit-jupiter-params"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withType&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Test&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;useJUnitPlatform&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Maven, add &lt;code&gt;junit-jupiter-params&lt;/code&gt; with test scope alongside &lt;code&gt;spring-boot-starter-test&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  IDE support
&lt;/h3&gt;

&lt;p&gt;IntelliJ IDEA renders each parameterized invocation as a child node in the Run tool window, using whatever display name you configure. It warns you at edit time if you declare &lt;code&gt;@ParameterizedTest&lt;/code&gt; without a source, highlights type mismatches between the source and the method signature, and excludes &lt;code&gt;@MethodSource&lt;/code&gt; provider methods from "unused declaration" warnings. The gutter run icon lets you execute the entire parameterized suite or rerun only the specific invocations that failed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running tests in Maven and Gradle
&lt;/h3&gt;

&lt;p&gt;Both build tools need &lt;code&gt;useJUnitPlatform()&lt;/code&gt; (Gradle) or the &lt;code&gt;maven-surefire-plugin&lt;/code&gt; configured for JUnit Platform to pick up Jupiter tests. Without this, parameterized tests either skip silently or report as errors rather than test failures.&lt;/p&gt;

&lt;p&gt;One &lt;a href="https://docs.junit.org/6.0.0/release-notes.html" rel="noopener noreferrer"&gt;JUnit 6 constraint&lt;/a&gt; worth knowing up front: Maven Surefire and Failsafe versions older than 3.0.0 are no longer supported. If your &lt;code&gt;pom.xml&lt;/code&gt; pins an older plugin version, bump it, or parameterized tests may not execute under the JUnit Platform at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;a href="https://docs.junit.org/6.1.1/writing-tests/display-names.html" rel="noopener noreferrer"&gt;Display names&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;@ParameterizedTest&lt;/code&gt; accepts a &lt;code&gt;name&lt;/code&gt; attribute. The placeholders &lt;code&gt;{index}&lt;/code&gt;, &lt;code&gt;{0}&lt;/code&gt;, &lt;code&gt;{1}&lt;/code&gt;, and &lt;code&gt;{arguments}&lt;/code&gt; let you build readable names from the supplied values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ParameterizedTest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"[{index}] discount for {0} loyalty years = {1}%"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test runner displays something like &lt;code&gt;[3] discount for 5 loyalty years = 10%&lt;/code&gt;, which makes failures self-explanatory without opening the test body.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using &lt;a href="https://docs.junit.org/6.1.1/writing-tests/parameterized-classes-and-tests.html#sources-ValueSource" rel="noopener noreferrer"&gt;@ValueSource&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@ValueSource&lt;/code&gt; supplies a single array of literals (strings, ints, longs, doubles, or class references) and is the right choice when your test needs one argument per invocation and the values are small enough to inline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ParameterizedTest&lt;/span&gt;
&lt;span class="nd"&gt;@ValueSource&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"radar"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"level"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"kayak"&lt;/span&gt;&lt;span class="o"&gt;})&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;isPalindrome&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;word&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;assertTrue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;IsPalindromeChecker&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;check&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;word&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When your data grows beyond three or four values, or when you need more than one argument per invocation, move to &lt;code&gt;@CsvSource&lt;/code&gt; or &lt;code&gt;@MethodSource&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using &lt;a href="https://docs.junit.org/6.1.1/writing-tests/parameterized-classes-and-tests.html#sources-null-and-empty" rel="noopener noreferrer"&gt;@NullSource, @EmptySource, and @NullAndEmptySource&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;These three annotations inject edge-case inputs for null and empty states. They're most useful on service methods and validators that need to handle blank input gracefully.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ParameterizedTest&lt;/span&gt;
&lt;span class="nd"&gt;@NullAndEmptySource&lt;/span&gt;
&lt;span class="nd"&gt;@ValueSource&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s"&gt;" "&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"\t"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"\n"&lt;/span&gt;&lt;span class="o"&gt;})&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;rejectsBlankUsername&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;assertThrows&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;InvalidInputException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;userService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;register&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"valid@example.com"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Combining &lt;code&gt;@NullAndEmptySource&lt;/code&gt; with &lt;code&gt;@ValueSource&lt;/code&gt; on the same method covers null, empty string, and whitespace-only inputs in one test method. JUnit 6 supports stacking these annotations.&lt;/p&gt;

&lt;p&gt;One interop detail matters if the method under test is written in Kotlin: a Kotlin function with a non-nullable parameter (&lt;code&gt;fun register(username: String, ...)&lt;/code&gt;) compiles a null-check at the start of the method body. Feed it a literal &lt;code&gt;null&lt;/code&gt; from &lt;code&gt;@NullSource&lt;/code&gt;, and you get a bare &lt;code&gt;NullPointerException&lt;/code&gt; before your own validation logic ever runs — not the &lt;code&gt;InvalidInputException&lt;/code&gt; the test expects. Declare the parameter as nullable (&lt;code&gt;username: String?&lt;/code&gt;) and check it with &lt;code&gt;username.isNullOrBlank()&lt;/code&gt; if you want &lt;code&gt;@NullAndEmptySource&lt;/code&gt; to exercise your validation path rather than Kotlin's own null-check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using &lt;a href="https://docs.junit.org/6.1.1/writing-tests/parameterized-classes-and-tests.html#sources-CsvSource" rel="noopener noreferrer"&gt;@CsvSource&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@CsvSource&lt;/code&gt; supplies multiple arguments per invocation as comma-separated rows defined inline. Use it when you need two or three related values, and you want to keep the data close to the test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ParameterizedTest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"{0} + {1} = {2}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@CsvSource&lt;/span&gt;&lt;span class="o"&gt;({&lt;/span&gt;
    &lt;span class="s"&gt;"1, 2, 3"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"5, 5, 10"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"-1, 1, 0"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"100, -100, 0"&lt;/span&gt;
&lt;span class="o"&gt;})&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;addsCorrectly&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;calculator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;JUnit handles type conversion automatically, so the string &lt;code&gt;"3"&lt;/code&gt; becomes the int &lt;code&gt;3&lt;/code&gt; without any adapter code. For values that contain commas, wrap the field in single quotes, as in &lt;code&gt;"'Smith, John', john@example.com, 30"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Under the hood, &lt;a href="https://docs.junit.org/6.0.0/release-notes.html" rel="noopener noreferrer"&gt;JUnit 6 replaced the CSV parser&lt;/a&gt; (it now uses FastCSV instead of univocity-parsers), and a few edge cases around quoting changed as a result — extra characters after a closing quote now throw instead of being silently ignored, for example. The common case above, including quoted fields containing commas, parses exactly the same as it did under JUnit 5.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@CsvSource&lt;/code&gt; starts to strain when you have more than six or seven rows, when values are long, or when you're reusing the same dataset across multiple test classes. At that point, use &lt;code&gt;@CsvFileSource&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Loading test data with &lt;a href="https://docs.junit.org/6.1.1/writing-tests/parameterized-classes-and-tests.html#sources-CsvFileSource" rel="noopener noreferrer"&gt;@CsvFileSource&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@CsvFileSource&lt;/code&gt; reads rows from a file on the classpath:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ParameterizedTest&lt;/span&gt;
&lt;span class="nd"&gt;@CsvFileSource&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/test-data/pricing-scenarios.csv"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;numLinesToSkip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;calculatesPriceCorrectly&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;loyaltyYears&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;isVip&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;expectedPrice&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// assertions&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;numLinesToSkip = 1&lt;/code&gt; skips the header row. The file sits under &lt;code&gt;src/test/resources&lt;/code&gt;. Reach for this approach when your test dataset is large enough to be maintained separately, when it's shared with non-Java stakeholders (QA, product), or when it's generated by another tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating dynamic test data with &lt;a href="https://docs.junit.org/6.1.1/writing-tests/parameterized-classes-and-tests.html#sources-MethodSource" rel="noopener noreferrer"&gt;@MethodSource&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@MethodSource&lt;/code&gt; delegates to a factory method that returns a &lt;code&gt;Stream&amp;lt;Arguments&amp;gt;&lt;/code&gt;, a &lt;code&gt;List&lt;/code&gt;, or an &lt;code&gt;Iterable&lt;/code&gt;. It's the right choice for complex objects, computed values, and any scenario where inline CSV can't express your data structure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ParameterizedTest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"{index}: {0}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@MethodSource&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"pricingScenarios"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;calculatesPriceForAllTiers&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PricingScenario&lt;/span&gt; &lt;span class="n"&gt;scenario&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;DiscountResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pricingService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;calculate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scenario&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scenario&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;expected&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;pricingScenarios&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PricingScenario&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"VIP with 5 loyalty years"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DiscountInput&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"200.00"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DiscountResult&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"150.00"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;)),&lt;/span&gt;
        &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PricingScenario&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"Standard user, no discount"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DiscountInput&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"100.00"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DiscountResult&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"100.00"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The factory method must be static by default because JUnit instantiates a new test class per invocation under the default &lt;code&gt;PER_METHOD&lt;/code&gt; lifecycle. You can lift that restriction with &lt;code&gt;@TestInstance(TestInstance.Lifecycle.PER_CLASS)&lt;/code&gt;, which becomes particularly useful in Kotlin (covered in a later section).&lt;/p&gt;

&lt;h2&gt;
  
  
  Supplying data with a custom &lt;a href="https://docs.junit.org/6.1.1/writing-tests/parameterized-classes-and-tests.html#sources-ArgumentsSource" rel="noopener noreferrer"&gt;ArgumentsProvider&lt;/a&gt;
&lt;/h2&gt;

&lt;p&gt;When you want to share a dataset across multiple test classes, or load data from an external source at runtime, implement &lt;code&gt;ArgumentsProvider&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DiscountScenariosProvider&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;ArgumentsProvider&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Override&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;provideArguments&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ParameterDeclarations&lt;/span&gt; &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ExtensionContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DiscountInput&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"100.00"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
                &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DiscountResult&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"90.00"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;)),&lt;/span&gt;
            &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DiscountInput&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"200.00"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
                &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DiscountResult&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"170.00"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then reference it with &lt;code&gt;@ArgumentsSource(DiscountScenariosProvider.class)&lt;/code&gt;. The provider has access to the &lt;code&gt;ExtensionContext&lt;/code&gt;, which means it can read annotations on the test class, inspect the test method name, or pull configuration from the JUnit store. That access is useful when you want a single provider to behave differently across different test contexts.&lt;/p&gt;

&lt;p&gt;Note the two-argument &lt;code&gt;provideArguments(ParameterDeclarations, ExtensionContext)&lt;/code&gt; signature above. The older single-argument &lt;code&gt;provideArguments(ExtensionContext)&lt;/code&gt; still compiles and runs under JUnit 6, but it's &lt;a href="https://docs.junit.org/6.0.0/release-notes.html" rel="noopener noreferrer"&gt;deprecated&lt;/a&gt; — the compiler will flag it, and new providers should use the two-argument form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing complex objects
&lt;/h2&gt;

&lt;p&gt;Passing plain primitives through &lt;code&gt;@CsvSource&lt;/code&gt; works until you need to test against DTOs, entity objects, or configuration objects. At that point, use &lt;code&gt;@MethodSource&lt;/code&gt; or a custom &lt;code&gt;ArgumentsProvider&lt;/code&gt; and construct your objects directly in the factory method.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DTOs.&lt;/strong&gt; For a Spring Boot user registration endpoint, a validation test might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ParameterizedTest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"{index}: {0}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@MethodSource&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"invalidRegistrationRequests"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;rejectsInvalidRequests&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;scenario&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;UserRegistrationRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;expectedViolations&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ConstraintViolation&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UserRegistrationRequest&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;violations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;validator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;validate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedViolations&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;scenario&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;invalidRegistrationRequests&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"blank username"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;UserRegistrationRequest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"valid@example.com"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"invalid email"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;UserRegistrationRequest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"alice"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"not-an-email"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"negative age"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;UserRegistrationRequest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"bob"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"bob@example.com"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"blank username and invalid email"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;UserRegistrationRequest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"not-an-email"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;scenario&lt;/code&gt; string argument doubles as a display name fragment and as an assertion message, so a failure tells you exactly which case broke without making you decode an index number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Entity objects.&lt;/strong&gt; The same pattern applies directly to JPA entities — construct them in the factory method and assert against their behavior:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ParameterizedTest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"{index}: {0} matches category {1}? {2}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@MethodSource&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"productsByCategory"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;filtersProductsByCategory&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;expectedMatch&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedMatch&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCategory&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;productsByCategory&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Laptop"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ELECTRONICS"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"999.00"&lt;/span&gt;&lt;span class="o"&gt;)),&lt;/span&gt; &lt;span class="s"&gt;"ELECTRONICS"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Laptop"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ELECTRONICS"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"999.00"&lt;/span&gt;&lt;span class="o"&gt;)),&lt;/span&gt; &lt;span class="s"&gt;"BOOKS"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Configuration objects.&lt;/strong&gt; Configuration-bound objects can be constructed directly too, which is useful for testing the logic that consumes them without spinning up a Spring context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ParameterizedTest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"{index}: enabled={0}, cap={1} -&amp;gt; bonus={2}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@MethodSource&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"pricingConfigurations"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;loyaltyBonusRespectsConfiguration&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;enabled&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;expectedBonus&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;PricingProperties&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PricingProperties&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enabled&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;bonus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isLoyaltyBonusEnabled&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLoyaltyBonusCap&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedBonus&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bonus&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;pricingConfigurations&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This verifies the logic that reads a configuration object in isolation. When you need to verify that Spring actually binds your &lt;code&gt;application.properties&lt;/code&gt; values into that object correctly, that's a job for &lt;code&gt;@SpringBootTest&lt;/code&gt; or &lt;code&gt;ApplicationContextRunner&lt;/code&gt;, covered next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parameterized testing in Spring Boot
&lt;/h2&gt;

&lt;p&gt;Combining &lt;code&gt;@ParameterizedTest&lt;/code&gt; with Spring test slice annotations gives you data-driven coverage across every layer of a Spring Boot application.&lt;/p&gt;

&lt;p&gt;Service layer tests run with plain constructor instantiation or with &lt;code&gt;@SpringBootTest&lt;/code&gt; and &lt;code&gt;@Autowired&lt;/code&gt; constructor injection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@SpringBootTest&lt;/span&gt;
&lt;span class="nd"&gt;@TestInstance&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TestInstance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Lifecycle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PER_CLASS&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PricingServiceTest&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Autowired&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;PricingService&lt;/span&gt; &lt;span class="n"&gt;pricingService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@ParameterizedTest&lt;/span&gt;
    &lt;span class="nd"&gt;@MethodSource&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"pricingCases"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;calculatesPriceCorrectly&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DiscountInput&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;DiscountResult&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pricingService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;calculate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;pricingCases&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DiscountInput&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;valueOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
                &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DiscountResult&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;valueOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;setScale&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validation tests follow the pattern shown earlier, building a &lt;code&gt;jakarta.validation.Validator&lt;/code&gt; directly (no Spring context required) and running each invalid payload through it.&lt;/p&gt;

&lt;p&gt;Before wiring up controller or repository tests, there's a dependency change worth knowing about: Spring Boot 4 split its test-support autoconfiguration into per-feature modules. &lt;code&gt;spring-boot-starter-test&lt;/code&gt; alone no longer pulls in &lt;code&gt;@WebMvcTest&lt;/code&gt; or &lt;code&gt;@DataJpaTest&lt;/code&gt; — each needs its own starter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;testImplementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.springframework.boot:spring-boot-starter-webmvc-test"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;testImplementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.springframework.boot:spring-boot-starter-data-jpa-test"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The annotations also moved packages. &lt;a href="https://docs.spring.io/spring-boot/reference/testing/spring-boot-applications.html#testing.spring-boot-applications.spring-mvc-tests" rel="noopener noreferrer"&gt;&lt;code&gt;@WebMvcTest&lt;/code&gt;&lt;/a&gt; is now &lt;code&gt;org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest&lt;/code&gt;, and &lt;a href="https://docs.spring.io/spring-boot/reference/testing/spring-boot-applications.html#testing.spring-boot-applications.autoconfigured-spring-data-jpa" rel="noopener noreferrer"&gt;&lt;code&gt;@DataJpaTest&lt;/code&gt;&lt;/a&gt; is &lt;code&gt;org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest&lt;/code&gt;. Spring Boot's own &lt;code&gt;@MockBean&lt;/code&gt; was removed in the same release — use Spring Framework's &lt;a href="https://docs.spring.io/spring-boot/reference/testing/spring-boot-applications.html#testing.spring-boot-applications.mocking-beans" rel="noopener noreferrer"&gt;&lt;code&gt;@MockitoBean&lt;/code&gt;&lt;/a&gt; (&lt;code&gt;org.springframework.test.context.bean.override.mockito.MockitoBean&lt;/code&gt;) in its place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Controller tests&lt;/strong&gt; with &lt;code&gt;@WebMvcTest&lt;/code&gt; and MockMvc work naturally with &lt;code&gt;@CsvSource&lt;/code&gt; or &lt;code&gt;@MethodSource&lt;/code&gt;. You build the request body, fire it through &lt;code&gt;mockMvc.perform()&lt;/code&gt;, and assert the response status per invocation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@WebMvcTest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserController&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserControllerParameterizedTest&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Autowired&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;MockMvc&lt;/span&gt; &lt;span class="n"&gt;mockMvc&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@MockitoBean&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;UserService&lt;/span&gt; &lt;span class="n"&gt;userService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@ParameterizedTest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"{index}: {0}/{1} -&amp;gt; {2}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@CsvSource&lt;/span&gt;&lt;span class="o"&gt;({&lt;/span&gt;
        &lt;span class="s"&gt;"alice, valid@example.com, 201"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"'', valid@example.com, 400"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"bob, not-an-email, 400"&lt;/span&gt;
    &lt;span class="o"&gt;})&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;registersUser&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;expectedStatus&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;mockMvc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;perform&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/users"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;APPLICATION_JSON&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""
                    {"username": "%s", "email": "%s", "age": 30}
                    """&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;formatted&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;andExpect&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;is&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedStatus&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Repository tests&lt;/strong&gt; with &lt;code&gt;@DataJpaTest&lt;/code&gt; can drive &lt;code&gt;@ParameterizedTest&lt;/code&gt; to verify query correctness across different filter combinations (entity status enums, date ranges, or tier codes) without repeating setup for each case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@DataJpaTest&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductRepositoryTest&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Autowired&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;ProductRepository&lt;/span&gt; &lt;span class="n"&gt;productRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@ParameterizedTest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"{index}: {0} has {1} product(s)"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@MethodSource&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"categoryExpectations"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;countsProductsByCategory&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;expectedCount&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;productRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Laptop"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ELECTRONICS"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"999.00"&lt;/span&gt;&lt;span class="o"&gt;)));&lt;/span&gt;
        &lt;span class="n"&gt;productRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Monitor"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ELECTRONICS"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"199.00"&lt;/span&gt;&lt;span class="o"&gt;)));&lt;/span&gt;
        &lt;span class="n"&gt;productRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Novel"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"BOOKS"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"15.00"&lt;/span&gt;&lt;span class="o"&gt;)));&lt;/span&gt;

        &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;productRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByCategory&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedCount&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;categoryExpectations&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ELECTRONICS"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2L&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"BOOKS"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Configuration property testing&lt;/strong&gt; with Spring's &lt;code&gt;ApplicationContextRunner&lt;/code&gt; lets you verify that each possible value for a feature flag or config property produces the expected binding, with every scenario named and independently reportable — without the overhead of a full &lt;code&gt;@SpringBootTest&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ParameterizedTest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"{index}: enabled={0}, cap={1} -&amp;gt; bonus={2}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@CsvSource&lt;/span&gt;&lt;span class="o"&gt;({&lt;/span&gt;
    &lt;span class="s"&gt;"true, 10, 10"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"true, 5, 5"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"false, 10, 0"&lt;/span&gt;
&lt;span class="o"&gt;})&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;loyaltyBonusRespectsConfiguration&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;enabled&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;expectedBonus&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ApplicationContextRunner&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withUserConfiguration&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PricingPropertiesConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;withPropertyValues&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"pricing.loyalty-bonus-enabled="&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;enabled&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"pricing.loyalty-bonus-cap="&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;PricingProperties&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBean&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PricingProperties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;bonus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isLoyaltyBonusEnabled&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;properties&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLoyaltyBonusCap&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
            &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedBonus&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bonus&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;});&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This combines Spring's test infrastructure with parameterized execution in both directions: the &lt;code&gt;ApplicationContextRunner&lt;/code&gt; builds a minimal context per invocation, and &lt;code&gt;@CsvSource&lt;/code&gt; drives the property values through it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@TestInstance(TestInstance.Lifecycle.PER_CLASS)&lt;/code&gt; is worth applying to Spring integration tests by default, because it allows &lt;code&gt;@MethodSource&lt;/code&gt; factory methods to live as instance methods rather than static methods. This is particularly useful in Kotlin, where a companion object with &lt;code&gt;@JvmStatic&lt;/code&gt; is the alternative and instance methods are the cleaner option.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaner parameterized tests with Kotlin
&lt;/h2&gt;

&lt;p&gt;Kotlin's interoperability with the JVM means you can call JUnit 6 from Kotlin without any adapters. The annotations work identically. The gain comes from what Kotlin removes from the picture: builder classes, &lt;code&gt;new&lt;/code&gt;, verbose static-factory-method requirements, and the noise that accumulates when you construct several complex objects inline.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to call JUnit 6 from Kotlin
&lt;/h3&gt;

&lt;p&gt;By default, JUnit's &lt;code&gt;@MethodSource&lt;/code&gt; requires a static factory method. Kotlin uses a companion object with &lt;code&gt;@JvmStatic&lt;/code&gt; for this purpose. You can avoid that boilerplate entirely by annotating the test class with &lt;code&gt;@TestInstance(TestInstance.Lifecycle.PER_CLASS)&lt;/code&gt;, which lets the factory method be a regular Kotlin instance function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@SpringBootTest&lt;/span&gt;
&lt;span class="nd"&gt;@TestInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TestInstance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Lifecycle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PER_CLASS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PricingServiceTest&lt;/span&gt; &lt;span class="nd"&gt;@Autowired&lt;/span&gt; &lt;span class="k"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;pricingService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PricingService&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;pricingCases&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DiscountInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"100.00"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;DiscountResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"90.00"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DiscountInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"200.00"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="nc"&gt;DiscountResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"170.00"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@ParameterizedTest&lt;/span&gt;
    &lt;span class="nd"&gt;@MethodSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"pricingCases"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`calculates&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="nf"&gt;correctly`&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DiscountInput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DiscountResult&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pricingService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;calculate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;a href="https://kotlinlang.org/docs/data-classes.html" rel="noopener noreferrer"&gt;Data classes&lt;/a&gt; for concise test cases
&lt;/h3&gt;

&lt;p&gt;A common pattern in Kotlin test suites is defining a small data class that bundles the inputs and the expected output for a single scenario. Because &lt;code&gt;data class&lt;/code&gt; auto-generates a readable &lt;code&gt;toString()&lt;/code&gt;, the test runner displays each invocation with a meaningful description rather than an index number.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@TestInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TestInstance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Lifecycle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PER_CLASS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PricingServiceParameterizedTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;pricingService&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PricingService&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;PricingTestCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DiscountInput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DiscountResult&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;pricingScenarios&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;PricingTestCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Standard user with no loyalty gets no discount"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DiscountInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"100.00"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;loyaltyYears&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isVip&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DiscountResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"100.00"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;PricingTestCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"VIP with high loyalty (&amp;gt;=5 yrs) gets 25%"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DiscountInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"200.00"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;loyaltyYears&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isVip&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DiscountResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"150.00"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@ParameterizedTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"{index} =&amp;gt; {0}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@MethodSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"pricingScenarios"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`should&lt;/span&gt; &lt;span class="n"&gt;calculate&lt;/span&gt; &lt;span class="n"&gt;discounted&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="nf"&gt;accurately`&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;testCase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;PricingTestCase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pricingService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;calculate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;testCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;testCase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The data class above is declared without &lt;code&gt;private&lt;/code&gt; — a public &lt;code&gt;@ParameterizedTest&lt;/code&gt; method can't expose a parameter type that's private to the class, so if you factor your test case class out, keep its visibility at least as public as the test method that consumes it.)&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;a href="https://kotlinlang.org/docs/functions.html#named-arguments" rel="noopener noreferrer"&gt;Named arguments&lt;/a&gt; for improved readability
&lt;/h3&gt;

&lt;p&gt;Kotlin named arguments make each test case self-documenting. Compare the Java version of a complex object construction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Java&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DiscountInput&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"200.00"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the Kotlin version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Kotlin&lt;/span&gt;
&lt;span class="nc"&gt;DiscountInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;basePrice&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"200.00"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;loyaltyYears&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isVip&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At a glance, the Kotlin version tells you what each argument means. Scale this across fifteen test cases and the readability difference compounds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cleaner object construction than Java builders
&lt;/h3&gt;

&lt;p&gt;When a Java constructor has more than a couple of same-typed parameters sitting next to each other (two ints, two booleans), teams often reach for a builder to make call sites self-describing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;DiscountInput&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DiscountInputBuilder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;basePrice&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"200.00"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;loyaltyYears&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isVip&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That builder exists purely to work around Java's lack of named arguments — it's an entire extra class (fields, a fluent setter per field, a &lt;code&gt;build()&lt;/code&gt; method) whose only job is call-site clarity. Kotlin's named arguments give you the same clarity directly at the call site, with no builder class to write or maintain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;input&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DiscountInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;basePrice&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"200.00"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;loyaltyYears&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isVip&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For test suites specifically, this matters more than it might in production code: a parameterized test's factory method constructs the same object repeatedly with different values, so every parameter it's missing a name for is a small tax paid on every single test case.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;a href="https://kotlinlang.org/docs/destructuring-declarations.html" rel="noopener noreferrer"&gt;Destructuring declarations&lt;/a&gt; and &lt;a href="https://kotlinlang.org/docs/extensions.html" rel="noopener noreferrer"&gt;extension functions&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;Destructuring declarations let you unpack a data class in the test method signature when you prefer to work with its fields directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;ValidationCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;UserRegistrationRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;expectedViolations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;validationCases&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;ValidationCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"valid payload"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;UserRegistrationRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"alice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"alice@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;ValidationCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"blank username"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;UserRegistrationRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"alice@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@ParameterizedTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"{index}: {0}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@MethodSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"validationCases"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`validates&lt;/span&gt; &lt;span class="n"&gt;registration&lt;/span&gt; &lt;span class="nf"&gt;requests`&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;testCase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ValidationCase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="py"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;expectedViolations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;testCase&lt;/span&gt;
    &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedViolations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;validator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Extension functions on the test class give you reusable assertion helpers scoped to the test context, which is a clean way to extract repeated assertion logic without moving it to a utility class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nc"&gt;DiscountResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertMatchesExpected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DiscountResult&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;discountedPrice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;discountedPrice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;discountPercent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;discountPercent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Java vs Kotlin side by side
&lt;/h3&gt;

&lt;p&gt;The same &lt;code&gt;@MethodSource&lt;/code&gt; test in Java and Kotlin illustrates where Kotlin cuts boilerplate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Java&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;pricingCases&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Stream&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DiscountInput&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"100.00"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DiscountResult&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"100.00"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DiscountInput&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"200.00"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DiscountResult&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"150.00"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Kotlin&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;pricingCases&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;DiscountInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;basePrice&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"100.00"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;loyaltyYears&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isVip&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;DiscountResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;discountedPrice&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"100.00"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;discountPercent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;DiscountInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;basePrice&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"200.00"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;loyaltyYears&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isVip&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;DiscountResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;discountedPrice&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"150.00"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;discountPercent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Kotlin version is shorter, each argument is named, and there's no &lt;code&gt;static&lt;/code&gt;, &lt;code&gt;new&lt;/code&gt;, or explicit &lt;code&gt;Stream.of()&lt;/code&gt;. The test assertions stay identical because you're calling the same JUnit 6 API against the same JVM bytecode.&lt;/p&gt;

&lt;p&gt;For teams with existing Java Spring Boot services, Kotlin test code compiles to the same bytecode and runs on the same JVM, so you can introduce Kotlin in the test source set (&lt;code&gt;src/test/kotlin&lt;/code&gt;) without touching a single production class. The two coexist without conflict, which reflects the strategic partnership between JetBrains and the Spring team, a collaboration aimed at improving the experience for backend developers on both sides of the language boundary. For a real-world account of Kotlin running mission-critical backend systems at scale, see this &lt;a href="https://blog.jetbrains.com/kotlin/2026/10/kotlinconf-recap-lessons-from-verifone-s-architect-on-kotlin-in-mission-critical-payments/" rel="noopener noreferrer"&gt;KotlinConf recap of Verifone's work on Kotlin in payments infrastructure&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best practices for maintainable parameterized tests
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep datasets focused.&lt;/strong&gt; A factory method that returns forty rows is probably testing more than one logical concept. Split by concern: one method for valid inputs, one for invalid inputs, and one for edge cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer descriptive argument names.&lt;/strong&gt; When your test case object has a description or scenario field, surface it in the display name with &lt;code&gt;{0}&lt;/code&gt;. A failure that reads &lt;code&gt;[3] VIP with 5 loyalty years gets 25% discount&lt;/code&gt; is actionable; &lt;code&gt;[3]&lt;/code&gt; is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate data generation from assertions.&lt;/strong&gt; The factory method should only construct data, and the assertion logic lives in the test method. This separation makes both easier to read and refactor independently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use reusable providers.&lt;/strong&gt; When the same dataset validates both a service and a controller, extract an &lt;code&gt;ArgumentsProvider&lt;/code&gt; implementation and reference it from both test classes with &lt;code&gt;@ArgumentsSource&lt;/code&gt;. Copying and pasting the factory method creates a maintenance problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Balance flexibility with readability.&lt;/strong&gt; A generic &lt;code&gt;@MethodSource&lt;/code&gt; that builds objects from a dozen intermediate variables is harder to follow than an explicit list of typed data class instances. Optimize for the next developer reading the failure report, not for line count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Organize shared datasets across modules.&lt;/strong&gt; In multi-module Gradle or Maven builds, a shared test-fixtures source set (Gradle's &lt;code&gt;java-test-fixtures&lt;/code&gt; plugin) lets you publish &lt;code&gt;ArgumentsProvider&lt;/code&gt; implementations that multiple modules can depend on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid over-parameterization.&lt;/strong&gt; If every test in a class is parameterized, the class is probably doing too much. Use parameterized tests for cases where variation in data genuinely changes the outcome. A test that always expects the same result with cosmetically varied inputs isn't verifying anything new.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose the simplest source that fits the problem&lt;/strong&gt; — whether &lt;code&gt;@ValueSource&lt;/code&gt; for a handful of scalars, &lt;code&gt;@CsvSource&lt;/code&gt; for related tuples that fit on one line each, &lt;code&gt;@MethodSource&lt;/code&gt; for complex objects, or a custom &lt;code&gt;ArgumentsProvider&lt;/code&gt; for shared or dynamically loaded datasets. Reaching for the most powerful option by default makes tests harder to scan than necessary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;JUnit 6's parameterized testing API covers how each source annotation works, why you'd choose one over another, and how the patterns map to real Spring Boot test suites across service, validation, controller, repository, and configuration layers.&lt;/p&gt;

&lt;p&gt;The Kotlin patterns covered here (data classes, named arguments, &lt;code&gt;@TestInstance(PER_CLASS)&lt;/code&gt;, extension functions) reduce test setup code without any production-side changes. For teams already on Spring Boot with Java production code, writing Kotlin in the test source set is a low-risk, incremental adoption path. The two languages share the same classpath, the same JUnit 6 runner, and the same Spring test infrastructure. Your production code stays exactly as it is while your test tables get measurably cleaner, which is the right way to evaluate any addition to a working stack: ask whether it solves a real friction point without demanding a wholesale commitment.&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>jvm</category>
      <category>junit</category>
      <category>testing</category>
    </item>
    <item>
      <title>AssertJ vs Kotest Assertions: picking the right assertion library for JVM tests</title>
      <dc:creator>Aniket Bhattacharyea</dc:creator>
      <pubDate>Wed, 21 Oct 2026 16:41:43 +0000</pubDate>
      <link>https://dev.to/heraldofsolace/assertj-vs-kotest-assertions-picking-the-right-assertion-library-for-jvm-tests-43in</link>
      <guid>https://dev.to/heraldofsolace/assertj-vs-kotest-assertions-picking-the-right-assertion-library-for-jvm-tests-43in</guid>
      <description>&lt;p&gt;&lt;a href="https://docs.junit.org/6.1.1/user-guide/" rel="noopener noreferrer"&gt;JUnit 6&lt;/a&gt; ships with &lt;code&gt;assertEquals&lt;/code&gt;, &lt;code&gt;assertTrue&lt;/code&gt;, 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 &lt;code&gt;expected: &amp;lt;true&amp;gt; but was: &amp;lt;false&amp;gt;&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this comparison works
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;User&lt;/code&gt; entity, a REST response DTO, and a repository query result.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Language design, syntax, and readability
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://assertj.github.io/doc/" rel="noopener noreferrer"&gt;AssertJ&lt;/a&gt; was designed for Java. Its API is a fluent chain of method calls, so &lt;code&gt;assertThat(user.getName()).isEqualTo("Alice").startsWith("A")&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kotest.io/docs/assertions/assertions.html" rel="noopener noreferrer"&gt;Kotest Assertions&lt;/a&gt; was built specifically for Kotlin. It uses infix functions and extension functions to produce assertions that read like natural language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AssertJ&lt;/span&gt;
&lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isEqualTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Alice"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isNotNull&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"@example.com"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Kotest&lt;/span&gt;
&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="s"&gt;"Alice"&lt;/span&gt;
&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shouldNotBeNull&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;shouldEndWith&lt;/span&gt; &lt;span class="s"&gt;"@example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;.isNotNull()&lt;/code&gt; first, then continue chaining. Kotest's &lt;code&gt;shouldNotBeNull()&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Exception assertions follow the same pattern. AssertJ uses &lt;a href="https://assertj.github.io/doc/#assertj-core-exception-assertions-assertThatThrownBy" rel="noopener noreferrer"&gt;&lt;code&gt;assertThatThrownBy&lt;/code&gt;&lt;/a&gt;, which wraps the code under test in a lambda. For Kotest, &lt;code&gt;shouldThrow&lt;/code&gt; does the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AssertJ&lt;/span&gt;
&lt;span class="nf"&gt;assertThatThrownBy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(-&lt;/span&gt;&lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isInstanceOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NotFoundException&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasMessageContaining&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"User not found"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Kotest&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;exception&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shouldThrow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotFoundException&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(-&lt;/span&gt;&lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="n"&gt;shouldContain&lt;/span&gt; &lt;span class="s"&gt;"User not found"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;AbstractThrowableAssert&lt;/code&gt;, and while you can chain off it, you lose the concrete type unless you cast (or reach for &lt;a href="https://assertj.github.io/doc/#assertj-core-exception-assertions-assertThatExceptionOfType" rel="noopener noreferrer"&gt;&lt;code&gt;assertThatExceptionOfType&lt;/code&gt;&lt;/a&gt;/&lt;code&gt;catchThrowableOfType&lt;/code&gt;, which do return the concrete type if you need it).&lt;/p&gt;

&lt;p&gt;For collection assertions, both libraries are capable, but their ergonomics differ. AssertJ uses &lt;code&gt;extracting&lt;/code&gt; and &lt;code&gt;containsExactly&lt;/code&gt; to navigate through collections, while Kotest delegates to standard Kotlin operations and then applies matchers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;users&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ADMIN"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"USER"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// AssertJ&lt;/span&gt;
&lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extracting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;containsExactly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ADMIN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"USER"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Kotest&lt;/span&gt;
&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;shouldHaveSize&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;shouldContainExactly&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ADMIN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"USER"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Kotest version reads like code you'd write anyway. The AssertJ version is clear, but &lt;code&gt;extracting("role")&lt;/code&gt; 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, &lt;code&gt;extracting(User::getRole)&lt;/code&gt;, that avoids this specific problem if you prefer a method reference over a string.)&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  IDE support and developer experience
&lt;/h2&gt;

&lt;p&gt;Both libraries work well inside IntelliJ IDEA, but their discoverability stories differ.&lt;/p&gt;

&lt;p&gt;AssertJ's API is a method chain off &lt;code&gt;assertThat(...)&lt;/code&gt;. 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 &lt;code&gt;Assertions.assertThat&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;user.name&lt;/code&gt; and then a space, and IntelliJ will suggest &lt;code&gt;shouldBe&lt;/code&gt;, &lt;code&gt;shouldNotBe&lt;/code&gt;, and every other matcher that applies to &lt;code&gt;String&lt;/code&gt;. The matchers are tied to the type system, so you won't see a string matcher when you're asserting on a number.&lt;/p&gt;

&lt;p&gt;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 (&lt;code&gt;shouldBeEqualIgnoringScale&lt;/code&gt;, &lt;code&gt;shouldContainExactlyInAnyOrder&lt;/code&gt;, and similar) — autocomplete narrows the list, but it doesn't substitute for reading the matcher catalog once.&lt;/p&gt;

&lt;p&gt;The Kotest project also publishes an &lt;a href="https://kotest.io/docs/intellij/intellij-plugin.html" rel="noopener noreferrer"&gt;IntelliJ IDEA plugin&lt;/a&gt;, 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 &lt;code&gt;BehaviorSpec&lt;/code&gt; and &lt;code&gt;DescribeSpec&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;AssertJ's experience is more static-import-dependent. On a new machine or a fresh project, getting &lt;code&gt;assertThat&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom assertions and soft assertions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Custom assertions
&lt;/h3&gt;

&lt;p&gt;Every meaningful Spring Boot project reaches the point where you're writing the same group of assertions in multiple test classes. A &lt;code&gt;User&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;With AssertJ, you have two patterns. The first uses Kotlin extension functions on &lt;code&gt;ObjectAssert&amp;lt;T&amp;gt;&lt;/code&gt;, which keeps your custom matchers chainable without subclassing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;ObjectAssert&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;.&lt;/span&gt;&lt;span class="nf"&gt;isActiveAdmin&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;ObjectAssert&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;satisfies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Consumer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isEqualTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ACTIVE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isEqualTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ADMIN"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// In tests&lt;/span&gt;
&lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;isNotNull&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isActiveAdmin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the explicit &lt;code&gt;Consumer { ... }&lt;/code&gt; wrapper around the lambda. AssertJ's &lt;code&gt;satisfies&lt;/code&gt; method has three overloads — one taking a &lt;code&gt;Condition&lt;/code&gt;, and two taking varargs of &lt;code&gt;Consumer&lt;/code&gt; or &lt;code&gt;ThrowingConsumer&lt;/code&gt; — 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 &lt;code&gt;Consumer { ... }&lt;/code&gt; resolves the ambiguity.&lt;/p&gt;

&lt;p&gt;The second pattern extends &lt;code&gt;AbstractAssert&lt;/code&gt; directly for complex domain objects where you want a dedicated assertion class with its own failure messages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserAssert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;?)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;AbstractAssert&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UserAssert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;UserAssert&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;hasValidEmail&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;UserAssert&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;isNotNull&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"@"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;failWithMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Expected valid email but was &amp;lt;%s&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;assertThatUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;UserAssert&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;UserAssert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// In tests&lt;/span&gt;
&lt;span class="nf"&gt;assertThatUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hasValidEmail&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here. First, &lt;code&gt;actual.email&lt;/code&gt; is nullable, so the check needs a safe call (&lt;code&gt;actual.email?.contains("@") != true&lt;/code&gt;) rather than a direct &lt;code&gt;.contains(...)&lt;/code&gt; call. Second, name your factory function something other than &lt;code&gt;assertThat&lt;/code&gt;. If you call it &lt;code&gt;assertThat(actual: User): UserAssert&lt;/code&gt; — which is the more common way this pattern is written — it will silently lose to &lt;code&gt;org.assertj.core.api.Assertions.assertThat&lt;/code&gt; 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 &lt;code&gt;assertThat(user)&lt;/code&gt; 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 &lt;code&gt;assertThatUser&lt;/code&gt; (or putting it behind an aliased import) avoids the collision entirely.&lt;/p&gt;

&lt;p&gt;Kotest handles custom assertions through extension functions on the subject itself, which fits naturally with how Kotlin is written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shouldBeActiveAdmin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ACTIVE&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="s"&gt;"ADMIN"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// In tests&lt;/span&gt;
&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shouldBeActiveAdmin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kotest also supports &lt;a href="https://kotest.io/docs/assertions/custom-matchers.html" rel="noopener noreferrer"&gt;&lt;code&gt;Matcher&amp;lt;T&amp;gt;&lt;/code&gt;&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;The Kotest approach produces less boilerplate and doesn't require you to understand the &lt;code&gt;AbstractAssert&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;data class&lt;/span&gt; &lt;span class="nc"&gt;ValidationResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// AssertJ&lt;/span&gt;
&lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isFalse&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"email must not be blank"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"age must be positive"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Kotest&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt; &lt;span class="n"&gt;shouldContainExactlyInAnyOrder&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"email must not be blank"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"age must be positive"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrapping either version in a &lt;code&gt;hasValidationErrors(vararg messages: String)&lt;/code&gt;-style custom assertion (an &lt;code&gt;AbstractAssert&lt;/code&gt; 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 &lt;code&gt;User&lt;/code&gt; examples above.&lt;/p&gt;

&lt;h3&gt;
  
  
  Soft assertions
&lt;/h3&gt;

&lt;p&gt;Both libraries support &lt;a href="https://assertj.github.io/doc/#assertj-core-soft-assertions" rel="noopener noreferrer"&gt;soft assertions&lt;/a&gt; (Kotest's &lt;a href="https://kotest.io/docs/assertions/soft-assertions.html" rel="noopener noreferrer"&gt;&lt;code&gt;assertSoftly&lt;/code&gt;&lt;/a&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AssertJ&lt;/span&gt;
&lt;span class="nc"&gt;SoftAssertions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertSoftly&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;softly&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;softly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isEqualTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CONFIRMED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;softly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;totalPrice&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isEqualByComparingTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"99.99"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;softly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hasSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Kotest&lt;/span&gt;
&lt;span class="nf"&gt;assertSoftly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CONFIRMED&lt;/span&gt;
    &lt;span class="n"&gt;totalPrice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shouldBeEqualIgnoringScale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"99.99"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="n"&gt;shouldHaveSize&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kotest's &lt;code&gt;assertSoftly&lt;/code&gt; block scopes the receiver, so you reference &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;items&lt;/code&gt;, and &lt;code&gt;totalPrice&lt;/code&gt; directly without repeating &lt;code&gt;softly.assertThat(order.x)&lt;/code&gt; on every line. The reduction in noise is real in test classes that verify complex aggregates like an &lt;code&gt;Order&lt;/code&gt; with nested line items or a validation result with multiple error messages. &lt;code&gt;shouldBeEqualIgnoringScale&lt;/code&gt; is the Kotest equivalent of AssertJ's &lt;code&gt;isEqualByComparingTo&lt;/code&gt; here — it compares the numeric value and ignores trailing-zero scale differences, so &lt;code&gt;BigDecimal("99.99")&lt;/code&gt; and &lt;code&gt;BigDecimal("99.990")&lt;/code&gt; are treated as equal. AssertJ's &lt;code&gt;assertSoftly&lt;/code&gt; requires the &lt;code&gt;softly&lt;/code&gt; prefix on every assertion line, which is more verbose but also more explicit about which assertion library you're calling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spring Boot integration
&lt;/h2&gt;

&lt;p&gt;AssertJ ships inside &lt;a href="https://docs.spring.io/spring-boot/reference/testing/spring-boot-applications.html" rel="noopener noreferrer"&gt;&lt;code&gt;spring-boot-starter-test&lt;/code&gt;&lt;/a&gt;. Add the Spring Boot test starter and AssertJ is available with no additional configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;testImplementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.springframework.boot:spring-boot-starter-test"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kotest Assertions requires an additional dependency. If you want Kotest's full test framework (its &lt;code&gt;FunSpec&lt;/code&gt;, &lt;code&gt;BehaviorSpec&lt;/code&gt;, and other styles), you also add the JUnit Platform runner. For teams that want only Kotest's assertion DSL inside JUnit 6 tests, &lt;code&gt;kotest-assertions-core&lt;/code&gt; is enough. If you're also mocking with &lt;a href="https://mockk.io/" rel="noopener noreferrer"&gt;MockK&lt;/a&gt; inside Spring test slices (as in the controller example below), add &lt;a href="https://github.com/Ninja-Squad/springmockk" rel="noopener noreferrer"&gt;&lt;code&gt;springmockk&lt;/code&gt;&lt;/a&gt; for its &lt;code&gt;@MockkBean&lt;/code&gt; annotation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;testImplementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.springframework.boot:spring-boot-starter-test"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;testImplementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"io.kotest:kotest-assertions-core:5.9.1"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// Optional: full Kotest framework&lt;/span&gt;
    &lt;span class="nf"&gt;testImplementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"io.kotest:kotest-runner-junit5:5.9.1"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;testImplementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"io.kotest.extensions:kotest-extensions-spring:1.3.0"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// Needed for @MockkBean in Spring test slices&lt;/span&gt;
    &lt;span class="nf"&gt;testImplementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.ninja-squad:springmockk:4.0.2"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both libraries integrate with the full Spring Boot test stack. A &lt;a href="https://docs.spring.io/spring-boot/reference/testing/spring-boot-applications.html#testing.spring-boot-applications.spring-mvc-tests" rel="noopener noreferrer"&gt;&lt;code&gt;@WebMvcTest&lt;/code&gt;&lt;/a&gt; controller test using MockK for mocking and Kotest for assertions looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@WebMvcTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserControllerTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Autowired&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;mockMvc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;MockMvc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@MockkBean&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;lateinit&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;UserService&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`should&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="nf"&gt;id`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;user&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"alex@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ACTIVE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;every&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;

        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mockMvc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/users/42"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;accept&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;APPLICATION_JSON&lt;/span&gt;
        &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;andExpect&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nf"&gt;status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;isOk&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;andReturn&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contentAsString&lt;/span&gt;

        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;dto&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;jacksonObjectMapper&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;readValue&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UserResponseDto&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="s"&gt;"alex@example.com"&lt;/span&gt;
        &lt;span class="n"&gt;dto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="s"&gt;"ACTIVE"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserServiceTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;UserService&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;InMemoryUserService&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;// AssertJ&lt;/span&gt;
    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`assertj&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt; &lt;span class="nf"&gt;test`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isEqualTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ACTIVE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Kotest&lt;/span&gt;
    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`kotest&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt; &lt;span class="nf"&gt;test`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ACTIVE&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For repository tests with &lt;a href="https://docs.spring.io/spring-boot/reference/testing/spring-boot-applications.html#testing.spring-boot-applications.autoconfigured-spring-data-jpa" rel="noopener noreferrer"&gt;&lt;code&gt;@DataJpaTest&lt;/code&gt;&lt;/a&gt;, the AssertJ version uses &lt;code&gt;satisfies&lt;/code&gt; to group assertions on a single result. &lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Java&lt;/span&gt;
&lt;span class="nd"&gt;@DataJpaTest&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserRepositoryJavaTest&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Autowired&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;UserRepository&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Autowired&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;TestEntityManager&lt;/span&gt; &lt;span class="n"&gt;em&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;shouldFindActiveUsersByDomain&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Alice"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"alice@company.com"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"USER"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ACTIVE&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Bob"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"bob@company.com"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"USER"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;INACTIVE&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;em&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;flush&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findActiveByDomain&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"company.com"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;hasSize&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;getEmail&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;isEqualTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"alice@company.com"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;getStatus&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;isEqualTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ACTIVE&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Kotlin&lt;/span&gt;
&lt;span class="nd"&gt;@DataJpaTest&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserRepositoryTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;@Autowired&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;UserRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;@Autowired&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;em&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TestEntityManager&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`should&lt;/span&gt; &lt;span class="n"&gt;find&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="nf"&gt;domain`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"alice@company.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ACTIVE&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"bob@company.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;INACTIVE&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;em&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;results&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findActiveByDomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"company.com"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;// AssertJ&lt;/span&gt;
        &lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hasSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;satisfies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Consumer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isEqualTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"alice@company.com"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isEqualTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ACTIVE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;

        &lt;span class="c1"&gt;// Kotest equivalent&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="n"&gt;shouldHaveSize&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="nf"&gt;with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;single&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="s"&gt;"alice@company.com"&lt;/span&gt;
            &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="nc"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ACTIVE&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As with the custom-assertion example earlier, &lt;code&gt;.satisfies(...)&lt;/code&gt; needs an explicit &lt;code&gt;Consumer { ... }&lt;/code&gt; wrapper in Kotlin — a bare trailing lambda doesn't compile against AssertJ's vararg overloads.&lt;/p&gt;

&lt;p&gt;If your repository tests run against a real database via &lt;a href="https://testcontainers.com/" rel="noopener noreferrer"&gt;Testcontainers&lt;/a&gt; instead of an embedded one, none of the assertion syntax above changes — &lt;code&gt;assertThat(...)&lt;/code&gt; and &lt;code&gt;shouldBe&lt;/code&gt;/&lt;code&gt;shouldHaveSize&lt;/code&gt; work the same way against a Testcontainers-backed &lt;code&gt;PostgreSQLContainer&lt;/code&gt; as they do against H2, since they're asserting on the &lt;code&gt;List&amp;lt;User&amp;gt;&lt;/code&gt; the repository returns, not on anything container-specific. The assertion library and the integration-test infrastructure are orthogonal choices.&lt;/p&gt;

&lt;p&gt;When using the full Kotest test framework alongside Spring Boot, you register &lt;a href="https://kotest.io/docs/extensions/spring.html" rel="noopener noreferrer"&gt;&lt;code&gt;SpringExtension&lt;/code&gt;&lt;/a&gt; to enable context loading and constructor-based dependency injection inside Kotest specs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@SpringBootTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webEnvironment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SpringBootTest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WebEnvironment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RANDOM_PORT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;UserServiceIntegrationTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;restTemplate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TestRestTemplate&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;FunSpec&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nf"&gt;extension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SpringExtension&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"should fetch user profile"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;restTemplate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getForEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/users/1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;statusCode&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OK&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shouldNotBeNull&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the constructor parameter has no &lt;code&gt;@Autowired&lt;/code&gt; annotation. Kotest's &lt;code&gt;SpringExtension&lt;/code&gt; resolves constructor dependencies itself, independently of Spring's own &lt;code&gt;TestConstructor&lt;/code&gt; autowire mode (which, under plain JUnit, defaults to requiring &lt;code&gt;@Autowired&lt;/code&gt; per parameter or a global property, as covered in our &lt;a href="https://dev.to/heraldofsolace/5-java-testing-frameworks-that-work-better-with-kotlin-32i1"&gt;companion piece&lt;/a&gt; on JUnit, Testcontainers, MockK, Kotest, and Spring Boot Test). Inside a Kotest spec specifically, it just works.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 (&lt;code&gt;FunSpec&lt;/code&gt;, &lt;code&gt;BehaviorSpec&lt;/code&gt;, 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 &lt;code&gt;kotest-assertions-core&lt;/code&gt; inside an ordinary JUnit &lt;code&gt;@Test&lt;/code&gt; method, that method is not a suspend function, and calling a suspend function from it fails to compile — you still need &lt;code&gt;runBlocking&lt;/code&gt; or &lt;code&gt;runTest&lt;/code&gt; around the call, exactly as you would with any other assertion library.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which assertion library to choose
&lt;/h2&gt;

&lt;p&gt;There's no universally correct answer, but the decision tree is short.&lt;/p&gt;

&lt;p&gt;Choose AssertJ when your team maintains both Java and Kotlin test sources and wants a single assertion style across both. AssertJ is already in &lt;code&gt;spring-boot-starter-test&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;assertSoftly&lt;/code&gt; scoping removes &lt;code&gt;softly.assertThat(...)&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;runner&lt;/em&gt;, 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.&lt;/p&gt;

&lt;p&gt;Mixing assertion libraries is viable within limits. Using Kotest Assertions inside JUnit 6 tests alongside AssertJ for specific cases (such as &lt;code&gt;Condition&lt;/code&gt;-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.&lt;/p&gt;

&lt;p&gt;Migrating from AssertJ to Kotest Assertions in a Kotlin codebase follows an incremental path: start with new test classes, use &lt;code&gt;shouldBe&lt;/code&gt; and &lt;code&gt;assertSoftly&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing based on language gravity
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;kotest-assertions-core&lt;/code&gt; 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, &lt;code&gt;kotlin.test&lt;/code&gt;, and full Spring Boot test integration. For a concrete account of Kotlin running mission-critical backend systems in production, see this &lt;a href="https://blog.jetbrains.com/kotlin/2026/10/kotlinconf-recap-lessons-from-verifone-s-architect-on-kotlin-in-mission-critical-payments/" rel="noopener noreferrer"&gt;KotlinConf recap of Verifone's work on Kotlin in payments infrastructure&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Pick the library that matches your codebase's language gravity. If your tests are trending Kotlin, Kotest Assertions will feel like a natural fit.&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>java</category>
      <category>jvm</category>
      <category>testing</category>
    </item>
    <item>
      <title>5 Java testing frameworks that work better with Kotlin</title>
      <dc:creator>Aniket Bhattacharyea</dc:creator>
      <pubDate>Thu, 15 Oct 2026 07:30:26 +0000</pubDate>
      <link>https://dev.to/heraldofsolace/5-java-testing-frameworks-that-work-better-with-kotlin-32i1</link>
      <guid>https://dev.to/heraldofsolace/5-java-testing-frameworks-that-work-better-with-kotlin-32i1</guid>
      <description>&lt;p&gt;Your test suite doesn't have to wait for production to meet Kotlin. If you're working on a Java Spring Boot project, you can start writing Kotlin tests today, against your existing Java code, without changing a single production class. Because Kotlin compiles to JVM bytecode, a Kotlin test can call any Java class directly, with no wrappers or adapters, which makes it a low-risk way to get familiar with the language.&lt;/p&gt;

&lt;p&gt;Kotlin needs roughly 40% less code than equivalent Java, and test code, which is mostly setup and assertions, benefits from that. This roundup covers five widely adopted libraries, from JUnit to Kotlin-native additions like MockK and Kotest, and shows how each works with Kotlin. You can add any of them to an existing project without touching the others.&lt;/p&gt;

&lt;h2&gt;
  
  
  How these tools were chosen
&lt;/h2&gt;

&lt;p&gt;Each tool here cleared three bars: being widely adopted and mature, offering first-class or excellent Kotlin support, and, together, the five cover the main testing layers, including unit, mocking, integration, assertions, and the full Spring context. You can add any one of them to an existing Java project without touching the others.&lt;/p&gt;

&lt;h2&gt;
  
  
  JUnit 6
&lt;/h2&gt;

&lt;p&gt;JUnit 6 is the latest version of JUnit, the de facto standard testing framework for JVM applications. If you're on a Java project, you may already be using JUnit, making it a natural entry point for writing your first Kotlin test.&lt;/p&gt;

&lt;p&gt;Kotlin's backtick function names noticeably improve test code readability. Take a Java test that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;shouldReturnDiscountedPrice&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;OrderService&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OrderService&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;assertEquals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;90.0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;applyDiscount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;100.0&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mf"&gt;0.001&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Kotlin equivalent reads like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`should&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;discounted&lt;/span&gt; &lt;span class="nf"&gt;price`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OrderService&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;90.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;applyDiscount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;100.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mf"&gt;0.001&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Backticks let you write full English sentences as test names, which appear directly in your test reports, with no camelCase decoding required.&lt;/p&gt;

&lt;p&gt;Parameterized tests also get cleaner in Kotlin. Using &lt;code&gt;@MethodSource&lt;/code&gt; with a companion object keeps your test data close to the test without the ceremony of a static utility class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;companion&lt;/span&gt; &lt;span class="k"&gt;object&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@JvmStatic&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;discountScenarios&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;100.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;90.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;200.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;150.0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;Arguments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;50.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;50.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@ParameterizedTest&lt;/span&gt;
&lt;span class="nd"&gt;@MethodSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"discountScenarios"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`applies&lt;/span&gt; &lt;span class="n"&gt;discount&lt;/span&gt; &lt;span class="nf"&gt;correctly`&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Double&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;OrderService&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;applyDiscount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="mf"&gt;0.001&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;JUnit's &lt;code&gt;@Nested&lt;/code&gt; tests also work well with Kotlin, but nested test classes must be declared as &lt;code&gt;inner class&lt;/code&gt; because JUnit requires nested test classes to be non-static:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Nested&lt;/span&gt;
&lt;span class="k"&gt;inner&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WhenDiscountIsApplied&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// tests&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kotlin works with JUnit 6 through standard JVM interoperability, with two differences from Java. Kotlin classes are final by default, but JUnit 6 test classes don't need to be open. For &lt;code&gt;@BeforeAll&lt;/code&gt; and &lt;code&gt;@AfterAll&lt;/code&gt;, you can either expose the methods as static JVM methods using a companion object and &lt;code&gt;@JvmStatic&lt;/code&gt;, or use JUnit's &lt;code&gt;PER_CLASS&lt;/code&gt; test instance lifecycle to keep them as instance methods.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testcontainers
&lt;/h2&gt;

&lt;p&gt;Testcontainers lets you run integration tests against real dependencies such as PostgreSQL, Kafka, and Redis by starting disposable Docker containers during the test run. Instead of relying on an in-memory substitute or a service installed on the developer's machine, your tests can exercise the same type of infrastructure your application uses.&lt;/p&gt;

&lt;p&gt;Kotlin's &lt;code&gt;apply&lt;/code&gt; scope function makes container configuration concise. &lt;code&gt;apply&lt;/code&gt; lets you call the container's methods directly inside the lambda, then returns the container itself, so you don't have to repeat the variable name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;postgres&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="nc"&gt;PostgreSQLContainer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;DockerImageName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"postgres:16-alpine"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;withDatabaseName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"testdb"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;withUsername&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"testuser"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;withPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"testpass"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For JUnit integration, &lt;code&gt;@Testcontainers&lt;/code&gt; and &lt;code&gt;@Container&lt;/code&gt; manage the container lifecycle. A container declared as a static field is shared across the test methods in that class, while an instance container is started and stopped for each test method.&lt;/p&gt;

&lt;p&gt;When using Kotlin, a shared container can be exposed as a JVM static field with &lt;code&gt;@JvmStatic&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Testcontainers&lt;/span&gt;
&lt;span class="nd"&gt;@SpringBootTest&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderRepositoryIntegrationTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;companion&lt;/span&gt; &lt;span class="k"&gt;object&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;@Container&lt;/span&gt;
        &lt;span class="nd"&gt;@JvmStatic&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;postgres&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
            &lt;span class="nc"&gt;PostgreSQLContainer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nc"&gt;DockerImageName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"postgres:16-alpine"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;withDatabaseName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"testdb"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;withUsername&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"testuser"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;withPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"testpass"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nd"&gt;@JvmStatic&lt;/span&gt;
        &lt;span class="nd"&gt;@DynamicPropertySource&lt;/span&gt;
        &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;DynamicPropertyRegistry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"spring.datasource.url"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;getJdbcUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"spring.datasource.username"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;getUsername&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"spring.datasource.password"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;getPassword&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`persists&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="n"&gt;retrieves&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="nc"&gt;ID`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// test against a real PostgreSQL instance&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@DynamicPropertySource&lt;/code&gt; registers dynamically determined values, such as a Testcontainers database URL and credentials, with the Spring &lt;code&gt;ApplicationContext&lt;/code&gt;. In Kotlin, Spring's documentation uses &lt;code&gt;@JvmStatic&lt;/code&gt; for both the container field and the dynamic-property method.&lt;/p&gt;

&lt;p&gt;For reusable test infrastructure, Kotlin extension functions can keep configuration helpers close to the types they operate on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;PostgreSQLContainer&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;*&amp;gt;.&lt;/span&gt;&lt;span class="nf"&gt;jdbcUrl&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jdbcUrl&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This can reduce repetition when several integration tests need similar container configuration.&lt;/p&gt;

&lt;p&gt;Testcontainers also supports modules for common dependencies such as PostgreSQL, Redis, and Kafka. Combining Testcontainers with Spring's context caching can cause failures in later test classes, though. JUnit stops a container when its test class finishes, but Spring may keep the &lt;code&gt;ApplicationContext&lt;/code&gt; for the next class, and that cached context can still point at the stopped container. If several test classes share a container, declare it as a Spring bean so its lifecycle follows the &lt;code&gt;ApplicationContext&lt;/code&gt; instead of a single test class.&lt;/p&gt;

&lt;h2&gt;
  
  
  MockK
&lt;/h2&gt;

&lt;p&gt;Mockito is widely used in Java projects and works with Kotlin, but Kotlin-specific language features can introduce some friction. Kotlin classes are final by default, while older Mockito setups often required additional configuration to mock final classes. Kotlin's &lt;code&gt;when&lt;/code&gt; keyword also conflicts with Mockito's &lt;code&gt;when()&lt;/code&gt; method, requiring escaped syntax such as &lt;code&gt;Mockito.`when`(...)&lt;/code&gt;. Mockito and the mockito-kotlin library support coroutine-based code, but MockK offers Kotlin-specific APIs that make stubbing and verification more idiomatic.&lt;/p&gt;

&lt;p&gt;MockK was designed specifically for Kotlin. It can mock final classes without additional configuration and provides a Kotlin-oriented DSL for stubbing and verification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;orderService&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mockk&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;

&lt;span class="nf"&gt;every&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;orderService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;99.99&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;invoiceService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateInvoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;verify&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;orderService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;99.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For suspending functions, MockK provides &lt;code&gt;coEvery&lt;/code&gt; and &lt;code&gt;coVerify&lt;/code&gt;, allowing coroutine calls to be stubbed and verified directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;coEvery&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;orderService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByIdAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt;
    &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;99.99&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;invoiceService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateInvoiceAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;coVerify&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;orderService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByIdAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;99.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MockK also provides a JUnit 5/6 extension that works with &lt;code&gt;@MockK&lt;/code&gt; annotations, avoiding the need to create each mock manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ExtendWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MockKExtension&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InvoiceServiceTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@MockK&lt;/span&gt;
    &lt;span class="k"&gt;lateinit&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;orderService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderService&lt;/span&gt;

    &lt;span class="c1"&gt;// tests&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For mocks where you only care about a subset of interactions, MockK's &lt;code&gt;relaxed = true&lt;/code&gt; option can provide default return values without requiring every call to be stubbed explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;orderService&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mockk&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;relaxed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For teams maintaining both Java and Kotlin tests, there's no need to migrate an existing Mockito-based test suite just to introduce MockK. Mockito and MockK can coexist in the same project, letting teams keep existing Java tests unchanged while using MockK for new Kotlin tests where its Kotlin-specific APIs are useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kotest assertions
&lt;/h2&gt;

&lt;p&gt;Kotest is a Kotlin testing ecosystem that provides a complete testing framework as well as standalone libraries for assertions, property-based testing, and other testing utilities. You can use Kotest's assertions and property testing in Kotlin tests while JUnit 6 remains the test framework, so Java teams can adopt Kotlin incrementally without changing their test runner. This section focuses on Kotest's assertion library and property-testing capabilities. Its test framework isn't covered in depth.&lt;/p&gt;

&lt;p&gt;Kotest's assertion APIs use Kotlin's language features to make test expectations more readable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`calculates&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="nf"&gt;total`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;total&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;calculateTotal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;49.99&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="mf"&gt;49.99&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of passing the actual and expected values to a traditional assertion method, &lt;code&gt;shouldBe&lt;/code&gt; expresses the relationship directly. Kotest provides similar assertions for collections, exceptions, strings, nullable values, and other common testing scenarios.&lt;/p&gt;

&lt;p&gt;These DSL-style assertions can make Kotlin tests more concise and expressive without requiring a team to replace its existing JUnit 6 test infrastructure.&lt;/p&gt;

&lt;p&gt;Kotest also provides property-based testing capabilities. Rather than checking a function against only a few manually selected examples, you can describe a property that should hold across a range of generated inputs.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;io.kotest.matchers.collections.shouldContainAll&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;io.kotest.matchers.shouldBe&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;io.kotest.property.Arb&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;io.kotest.property.arbitrary.int&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;io.kotest.property.arbitrary.list&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;io.kotest.property.checkAll&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;kotlinx.coroutines.test.runTest&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.junit.jupiter.api.Test&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SortingPropertyTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`sorting&lt;/span&gt; &lt;span class="n"&gt;preserves&lt;/span&gt; &lt;span class="n"&gt;all&lt;/span&gt; &lt;span class="nf"&gt;elements`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;checkAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Arb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Arb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;numbers&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;sorted&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;numbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

            &lt;span class="n"&gt;sorted&lt;/span&gt; &lt;span class="n"&gt;shouldContainAll&lt;/span&gt; &lt;span class="n"&gt;numbers&lt;/span&gt;
            &lt;span class="n"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="n"&gt;shouldBe&lt;/span&gt; &lt;span class="n"&gt;numbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test describes properties that should always hold, while Kotest generates multiple inputs to check those properties. This can help uncover edge cases that a small set of hand-written examples might not exercise.&lt;/p&gt;

&lt;p&gt;Property-based testing is particularly useful when code has clear invariants, such as sorting, serialization, parsing, validation, or mathematical operations. Existing JUnit 6 tests can remain unchanged while new Kotlin tests use Kotest's assertion APIs. You can then add property-based testing selectively where generated inputs add value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spring Boot Test
&lt;/h2&gt;

&lt;p&gt;For Spring Boot applications, &lt;code&gt;spring-boot-starter-test&lt;/code&gt; provides the testing infrastructure needed to test Spring components and application behavior in a realistic application context. Kotlin works with the same Spring Boot testing infrastructure used by Java applications, while Kotlin-specific features can make some test code more concise.&lt;/p&gt;

&lt;p&gt;Spring Boot Test is particularly useful when a test needs more than an isolated unit test, for example, when you want to verify how multiple Spring-managed components work together.&lt;/p&gt;

&lt;p&gt;The most practical improvement is constructor injection in test classes. In Java, &lt;code&gt;@SpringBootTest&lt;/code&gt; tests typically use &lt;code&gt;@Autowired&lt;/code&gt; field injection, which means mutable fields and no compile-time guarantee that dependencies are actually available. In Kotlin, you can use &lt;code&gt;@TestConstructor(autowireMode = AutowireMode.ALL)&lt;/code&gt; to wire dependencies through the primary constructor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ExtendWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SpringExtension&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@TestConstructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autowireMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TestConstructor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AutowireMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ALL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@SpringBootTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webEnvironment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="nc"&gt;SpringBootTest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WebEnvironment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;RANDOM_PORT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;internal&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderApiIntegrationTest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;orderRepository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;OrderRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;testRestTemplate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TestRestTemplate&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;`GET&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="nf"&gt;list`&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;49.99&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;testRestTemplate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getForEntity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;java&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To set this globally for all test classes, add &lt;code&gt;spring.test.constructor.autowire.mode=all&lt;/code&gt; to &lt;code&gt;src/test/resources/junit-platform.properties&lt;/code&gt;. All your Kotlin test classes then get constructor injection automatically.&lt;/p&gt;

&lt;p&gt;Kotlin works with all standard Spring test slice annotations, including &lt;code&gt;@DataJpaTest&lt;/code&gt;, &lt;code&gt;@WebMvcTest&lt;/code&gt;, and &lt;code&gt;@AutoConfigureMockMvc&lt;/code&gt;. Spring Framework's Kotlin extensions add idiomatic API variants where the Java API feels verbose, particularly for MockMvc and WebTestClient.&lt;/p&gt;

&lt;p&gt;For integration tests in mixed Java/Kotlin projects, a useful pattern is a Kotlin base class that declares the &lt;code&gt;@Testcontainers&lt;/code&gt; setup and &lt;code&gt;@DynamicPropertySource&lt;/code&gt;, combined with Spring Boot Test's context-caching behavior. Both JUnit Java tests and Kotlin tests can extend the same base class, reusing the same application context and container instance across the test run.&lt;/p&gt;

&lt;p&gt;JetBrains and Spring formalized a strategic partnership in May 2025 to improve the Kotlin developer experience in Spring applications. The collaboration covers full null-safety integration for Kotlin in Spring, official Spring learning materials in Kotlin, and enhanced tooling in IntelliJ IDEA. The same announcement reports that 27% of Spring developers use Kotlin.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do next
&lt;/h2&gt;

&lt;p&gt;These five tools cover the full testing stack, from unit mocks to full-context integration tests, and you can adopt each one at a time without touching your production code. A practical starting point is adding Kotlin to your test source set, writing your next new test in Kotlin using JUnit 6, and pulling in MockK when you hit the first case where Mockito's final-class behavior gets in the way.&lt;/p&gt;

&lt;p&gt;Kotlin's concise syntax, null safety, extension functions, and DSLs carry over from test code to production code, which is why the test suite is a natural on-ramp. Once your team has written a few dozen Kotlin tests and developed an intuition for the idioms, expanding Kotlin into production services becomes a lower-risk conversation.&lt;/p&gt;

&lt;p&gt;kotlinlang.org has the language reference, coding conventions, and getting-started guides for JVM and Spring Boot development. The official JUnit testing tutorial for Kotlin covers the Gradle setup steps in detail.&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>java</category>
      <category>junit</category>
      <category>testing</category>
    </item>
    <item>
      <title>6 Business Levers to Evaluate Before Adopting a New Language</title>
      <dc:creator>Aniket Bhattacharyea</dc:creator>
      <pubDate>Thu, 15 Oct 2026 07:12:50 +0000</pubDate>
      <link>https://dev.to/heraldofsolace/6-business-levers-to-evaluate-before-adopting-a-new-language-11e4</link>
      <guid>https://dev.to/heraldofsolace/6-business-levers-to-evaluate-before-adopting-a-new-language-11e4</guid>
      <description>&lt;p&gt;A language proposal framed purely as a productivity argument is incomplete. The real decision touches hiring pipelines, incident rates, migration timelines, audit controls, and long-term strategic positioning. Verifone's experience with Kotlin illustrates why. In a large, regulated payments environment, adopting a new language is a targeted decision about which systems benefit and which should stay untouched.&lt;/p&gt;

&lt;p&gt;This guide provides a repeatable six-lever framework for making that decision. Kotlin appears as a concrete example throughout, but the framework itself applies to any language under consideration.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Lever 1: Delivery throughput and time-to-market&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The hypothesis behind most language adoption proposals is that developers will ship faster. The burden of proof is yours to assign, meaning faster compared to what baseline, on what kind of work, and measured how?&lt;/p&gt;

&lt;p&gt;Start with your current delivery metrics. &lt;a href="https://dora.dev/guides/dora-metrics/" rel="noopener noreferrer"&gt;DORA's&lt;/a&gt; lead time for changes, deployment frequency, change failure rate, and &lt;a href="https://www.ibm.com/think/topics/mttr" rel="noopener noreferrer"&gt;MTTR&lt;/a&gt; can provide a baseline, supplemented by domain-specific metrics. If you don't have those, instrument your metrics pipelines before you run any pilot. Also, define a kill switch before the pilot. If build times, incident rates, review overhead, or required security controls move beyond agreed thresholds, pause expansion and reassess.&lt;/p&gt;

&lt;p&gt;You're trying to determine whether the language increases delivery capacity or reduces the cost of delay, because shipping sooner on a high-value feature has a measurable dollar value. Reduced boilerplate and safer refactoring are two mechanisms that need to produce shorter cycles at the product level.&lt;/p&gt;

&lt;p&gt;Kotlin, for example, produces roughly &lt;a href="https://kotlinlang.org/docs/faq.html#what-advantages-does-kotlin-give-me-over-the-java-programming-language" rel="noopener noreferrer"&gt;40% fewer lines of code&lt;/a&gt; than equivalent Java, and &lt;a href="https://kotlinlang.org/docs/faq.html" rel="noopener noreferrer"&gt;JetBrains documents&lt;/a&gt; that Kotlin developers spend 15 to 20% less wall-clock time on comparable work. Those are plausible throughput signals, but they mean nothing if your actual bottleneck is unclear requirements or slow code review. A bounded pilot on one team's new module, measured against your existing lead-time data, tells you whether the throughput hypothesis holds for your context before you commit the organization.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Lever 2: Defect cost avoidance&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Built-in mechanisms that prevent particular classes of defects are only a business case once they're tied to what those defects actually cost you.&lt;/p&gt;

&lt;p&gt;Start by identifying your most expensive defect categories, including severity-1 incidents, emergency releases, support escalations, regulatory findings, and customer-impacting bugs. Then ask whether the language you're evaluating has type-system mechanisms that would have caught those failures at compile time.&lt;/p&gt;

&lt;p&gt;Kotlin's null safety is built directly into the type system. Variables are non-nullable by default, so the compiler enforces that distinction at compile time. For codebases where NullPointerExceptions show up regularly in incident retrospectives, that's a targeted reduction in a specific defect class. Sealed classes and exhaustive when expressions push domain modeling errors to compile time, catching incomplete state handling before it reaches production.&lt;/p&gt;

&lt;p&gt;Track the specific defect classes your type system would constrain, assign a cost per incident from your post-mortem data, and project the reduction. That number, even as an estimate, grounds the conversation in outcomes.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Lever 3: Legacy modernization without full-rewrite risk&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The highest-risk path to adopting a new language involves a full-rewrite, where you freeze features, rebuild from scratch, deliver the replacement months or years later, and hope the codebase didn't drift and the team didn't burn out.&amp;nbsp;&lt;/p&gt;

&lt;p&gt;Evaluate whether the language supports incremental adoption and what that path concretely looks like for your estate. Can you introduce the language in new modules while leaving existing code untouched? Can both languages call each other without a translation layer? Can you migrate active systems module by module while continuing to ship features? You can opt for an incremental adoption that doesn't involve migrating every legacy system. Stable, low-change systems may not generate enough benefit to justify the migration cost. In those cases, leaving the existing system in place can be the economically rational choice.&amp;nbsp;&lt;/p&gt;

&lt;p&gt;Big companies, especially those with payments environment shows why this incremental approach matters in practice. With dozens of systems across dozens of engineering squads and strict EMV L3, PCI DSS, and ISO 8583 requirements, a language migration cannot reasonably be treated as a single organization-wide rewrite.&amp;nbsp;&lt;/p&gt;

&lt;p&gt;Kotlin's Java interoperability instead allows adoption to be evaluated within individual domains and systems, while existing Java systems continue to operate. The broader lesson is to treat modernization as a portfolio of bounded investment decisions. Target systems where the expected benefits justify the migration cost, while keeping stable, low-change systems in place when they do not.&lt;/p&gt;

&lt;p&gt;When evaluating an incremental migration, ask these questions: where can you introduce the new language with a limited blast radius, which systems justify migration, and which stable or end-of-life systems should remain untouched?&lt;/p&gt;

&lt;p&gt;IntelliJ IDEA includes a built-in Java-to-Kotlin converter that handles mechanical conversion, and AI-assisted refactoring in the IDE supports larger transformations where test coverage is solid. JetBrains' documented adoption playbook has distinct stages. Start with tests, introduce new modules, migrate active systems module by module, and leave end-of-life applications untouched.&lt;/p&gt;

&lt;p&gt;When you build your migration cost model, include the full picture. Conversion effort is the smallest line item. The real costs are training time, mixed-language code review overhead, governance and style guide updates, tooling configuration for two-language CI pipelines, parallel operation periods, compliance documentation for new dependencies, and eventual cleanup. Teams that model only conversion effort routinely underestimate total migration cost by a significant margin.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Lever 4: Talent economics&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Language adoption changes your hiring market, your onboarding timeline, and your knowledge concentration risk. All three have an associated financial cost.&lt;/p&gt;

&lt;p&gt;Broad developer popularity rankings (TIOBE, Stack Overflow survey rank) tell you about global sentiment, not about the candidate pool available in your geography and domain. A financial services firm hiring in Frankfurt has a different Kotlin talent market than a startup hiring remotely across North America. Run the actual job-search data for your location and role tier before drawing conclusions from global statistics.&lt;/p&gt;

&lt;p&gt;For most JVM shops, you need to know whether existing engineers can transfer skills into the new language and how long that takes. Kotlin is a evolution of Java, so a Java developer with solid JVM fundamentals can often build on existing knowledge, but time to productive, idiomatic Kotlin varies by team, domain, and training approach. &lt;a href="https://www.jetbrains.com/pages/academy/kotlin/" rel="noopener noreferrer"&gt;JetBrains Academy&lt;/a&gt; provides structured Kotlin learning in JetBrains IDEs, including Kotlin Onboarding and Kotlin Koans.&lt;/p&gt;

&lt;p&gt;Knowledge concentration risk increases if your Kotlin adoption concentrates expertise in a small number of senior engineers who championed the migration, creating a bus-factor problem. Plan for knowledge distribution through pair programming, internal documentation, and making Kotlin adoption part of the standard onboarding path for new hires.&lt;/p&gt;

&lt;p&gt;Whether a smaller external talent pool is manageable depends on your attrition rate, your time-to-hire baseline, and your planned pace of adoption. If JVM continuity means your Java engineers ramp quickly and internal training covers the difference, the hiring constraint often stays within tolerance.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Lever 5: Multichannel consistency&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;This lever applies only when your organization runs business logic across multiple platforms and duplicated implementations create real cost or behavioral drift. You don't need to worry about this if you're building a backend-only product.&lt;/p&gt;

&lt;p&gt;For organizations that ship a mobile app, a web frontend, and a backend service where the same validation rules, pricing calculations, or domain models must behave identically across channels, duplicated implementations in different languages become a maintenance liability. When the backend rule changes, all three implementations need to change, and the risk of divergence grows with velocity.&lt;/p&gt;

&lt;p&gt;Kotlin Multiplatform (KMP) lets you write shared modules in Kotlin and compile them to JVM, JavaScript, and native targets. You can implement a pricing rule or validation function in shared Kotlin code and compile it for Android, iOS, and JVM targets, reducing the need to maintain separate implementations.&lt;/p&gt;

&lt;p&gt;Shared code is worth the added complexity only when it eliminates meaningful duplication across channels you already operate, not as a hypothetical future benefit. Be aware that Kotlin Multiplatform is a separate architectural decision from adopting Kotlin on the JVM, and you can adopt Kotlin for server-side development without committing to KMP.&amp;nbsp;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Lever 6: Security, compliance, and auditability&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;In regulated environments and security-sensitive industries, language adoption is a supply-chain event. Introducing a new language adds runtime artifacts, transitive dependencies, toolchain components, and new questions for your compliance team. You should evaluate that surface area before you commit.&lt;/p&gt;

&lt;p&gt;On the supply-chain side, ask whether the language's standard library has a documented security support policy with formal end-of-support dates, CVE assignment processes, and a public vulnerability disclosure page. Starting with Kotlin 2.4.0, the Kotlin standard library for the JVM has an 18-month support window for each release line. Security fixes are backported to all active release lines and given CVE identifiers, published on the JetBrains Fixed Security Issues page. That policy structure is directly relevant for teams that need documented evidence of dependency support coverage for auditors.&lt;/p&gt;

&lt;p&gt;On the SAST and auditability side, verify that your existing static analysis tooling covers the new language. JetBrains' Qodana integrates into CI/CD pipelines via GitHub Actions, GitLab CI/CD, Jenkins, and TeamCity, and runs Kotlin-specific inspections alongside Java analysis in mixed codebases. Within IntelliJ IDEA, real-time inspections flag null-safety violations and other issues before code reaches the pipeline. For SBOM generation, the Kotlin ecosystem supports CycloneDX-format SBOM plugins for Gradle, which feed into existing compliance workflows.&lt;/p&gt;

&lt;p&gt;Be aware that introducing a second language can increase audit surface area if it multiplies the runtime artifacts and toolchain components your security team must track. It can also reduce audit surface area if the new language's type system eliminates categories of vulnerabilities your current codebase generates regularly. Model both directions for your specific regulatory context, and don't assume that one dominates.&lt;/p&gt;

&lt;p&gt;Security is only one part of the risk calculation. Enterprise teams should also assess governance concentration, vendor and ecosystem risk, release and support windows, build and CI overhead, operational tooling, and the additional 'polyglot tax' of maintaining more than one language. These costs should be measured during the pilot.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Turn the checklist into an investment case&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;To use these six levers to decide whether adoption is worth funding, baseline the target domain's current cost and performance, model the expected benefits conservatively, and include the learning curve, mixed-language overhead, tooling, governance, compliance, parallel operation, and eventual decommissioning. For larger initiatives, translate the resulting cash flows into metrics such as &lt;a href="https://kotlinlang.org/lp/enterprise-playbook/" rel="noopener noreferrer"&gt;NPV, IRR, and payback period&lt;/a&gt;. Run conservative, base, and upside scenarios, and define the conditions that would cause you to pause further rollout.&lt;/p&gt;

&lt;p&gt;A language can look attractive on individual metrics while still producing a negative investment case for a particular system.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Apply the 6 levers as a checklist&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Before committing to any language adoption, work through each lever with your team and document the answers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Delivery throughput:&lt;/strong&gt; what's your lead-time baseline, and how will you validate the throughput hypothesis in a bounded pilot?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Defect cost avoidance:&lt;/strong&gt; which specific defect classes would the type system constrain, and what does each class cost you?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Legacy modernization:&lt;/strong&gt; what does an incremental adoption path look like, and does your total migration cost model include training, governance, and parallel-operation overhead?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Talent economics:&lt;/strong&gt; what does the actual candidate market look like in your geography, and how long do your existing engineers take to reach productive output?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Multichannel consistency:&lt;/strong&gt; does your organization actually have duplicated implementations across channels, and would shared code meaningfully reduce that cost?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Security and compliance:&lt;/strong&gt; what does the language add to your supply-chain surface, and what does your security tooling coverage look like for the new language?&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Different systems and teams will produce different answers, and the same language can score differently across your own portfolio.&lt;/p&gt;

&lt;p&gt;Enterprises' experience illustrates the broader principle: language adoption needs to be an investment decision, backed by numbers your team can defend. The right answer can differ by system, team, and business domain.&lt;/p&gt;

&lt;p&gt;Use the six levers to establish your baseline, identify where the candidate language can create measurable value, account for migration and operating costs, and determine where adoption is and isn't justified.&lt;/p&gt;

&lt;p&gt;If you're evaluating Kotlin specifically, the &lt;a href="https://kotlinlang.org/lp/enterprise-playbook/" rel="noopener noreferrer"&gt;A Legacy Modernization Strategy and Business Case Playbook for Enterprise Kotlin Adoption&lt;/a&gt; provides a concrete example of how incremental adoption can work in an enterprise environment. What language decisions is your team currently weighing? Share your evaluation criteria in the comments.&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>language</category>
      <category>programming</category>
      <category>business</category>
    </item>
    <item>
      <title>Kotlin Governance Framework: How Enterprises Modernize Without Losing Control</title>
      <dc:creator>Aniket Bhattacharyea</dc:creator>
      <pubDate>Thu, 15 Oct 2026 07:03:05 +0000</pubDate>
      <link>https://dev.to/heraldofsolace/kotlin-governance-framework-how-enterprises-modernize-without-losing-control-ohe</link>
      <guid>https://dev.to/heraldofsolace/kotlin-governance-framework-how-enterprises-modernize-without-losing-control-ohe</guid>
      <description>&lt;p&gt;Technology modernization usually starts with a technical choice. You choose the most appropriate language, framework, or stack for your case. But in practice, even the best choice can go wrong if it's applied unevenly across teams.&amp;nbsp;&lt;/p&gt;

&lt;p&gt;One group adopts Kotlin while another is still debating whether to allow it, and a few months in, whoever shipped code that week is setting the architecture, with no way to tell the finance committee what the return on investment actually was.&lt;/p&gt;

&lt;p&gt;This guide lays out a governance framework for successful modernization built around five pillars: financial evaluation, ownership structure, architectural standards, coding conventions, and the enablement activities that keep adoption going. It is based on a real-world enterprise transition led by Yuri at Verifone (for further details, see &lt;a href="https://www.youtube.com/watch?v=lo8uh-HU1HI" rel="noopener noreferrer"&gt;Yuri's KotlinConf talk and case study&lt;/a&gt;).&amp;nbsp;&lt;/p&gt;

&lt;p&gt;While written around modernizing Java codebases with Kotlin, the same framework applies more broadly across stack transitions.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Financial governance&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Technology modernization proposals compete for capital against infrastructure work, product features, and operational improvements. To win those budget conversations and make sound decisions, engineering leaders need the same financial vocabulary that finance committees use.&lt;/p&gt;

&lt;p&gt;Three models carry the most weight in stack modernization decisions. &lt;strong&gt;Net Present Value (NPV)&lt;/strong&gt; calculates the present worth of all future cash flows from the initiative, discounting them at the organization's required rate of return. A positive NPV indicates value creation at the chosen discount rate, although organizations may set a higher portfolio threshold. &lt;strong&gt;Internal Rate of Return (IRR)&lt;/strong&gt; is the discount rate at which the initiative breaks even. An IRR above your organization's cost of capital signals the project creates value. &lt;strong&gt;Total Cost of Ownership (TCO)&lt;/strong&gt; captures what NPV and IRR often miss by including the ongoing operational costs of running the modernized stack, such as toolchain licensing, training, maintenance, and any migration-period overhead where two runtimes operate in parallel.&lt;/p&gt;

&lt;p&gt;For Kotlin adoption in a Java-dominant codebase, NPV calculations should account for the transition window upfront. A credible business case needs an estimate of how much duplicated toolchain, review, and dependency overhead adds to the NPV while both languages run in parallel.&lt;/p&gt;

&lt;p&gt;Capital allocation gates translate NPV, IRR, and TCO into decision checkpoints. A practical structure runs three gates, starting with an initial feasibility gate requiring a documented NPV estimate and a codebase classification (covered in the Enablement Activities section), then a pilot gate requiring measured productivity and quality data from an initial adoption module, and finally a scale gate releasing broader investment only after the pilot validates the NPV model. Without these gates, modernization programs tend to either stall at the pilot stage indefinitely or expand faster than governance structures can support.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Ownership structure and governance roles&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A lack of defined ownership structure and governance roles often causes the adoption process to fail. Teams end up waiting for decisions no one is empowered to make, or the process sprawls beyond control, as different teams introduce conflicting patterns, incompatible dependency versions, and local conventions that make cross-team code reviews painful.&lt;/p&gt;

&lt;p&gt;A governance structure that assigns four named owner groups, each with a distinct scope, helps fix this by making sure every decision has a clear owner.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Architecture Board&lt;/strong&gt; is the cross-functional decision body responsible for module classification governance, the organization's technology radar, and change gatekeeping. It approves which modules are candidates for Kotlin adoption, maintains the approved technology list, and reviews architectural patterns before they propagate across teams. Board membership typically spans architects and CTOs from multiple business functions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Platform Engineering&lt;/strong&gt; owns the toolchain baseline that every team runs on. This includes standardized build systems’ configurations, the approved Kotlin version matrix, shared CI/CD pipeline templates, and the artifact repository policies that control which dependencies can enter the build. When Platform Engineering ships a build system convention plugin that encodes the approved Detekt ruleset and Ktlint configuration, teams get compliant pipelines by default, without extra work on their part.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Security and Compliance&lt;/strong&gt; group owns supply chain controls and risk assessment for the technology transition. For Kotlin adoption specifically, this means evaluating the Software Bill of Materials (SBOM) implications of new Kotlin-specific dependencies, setting policy on how platform types from Java interop are handled in security-sensitive code paths, and reviewing the process for Kotlin compiler and library upgrades.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Kotlin Guild&lt;/strong&gt; is the community layer that the other three groups depend on to function. The Kotlin Guild provides the practitioner layer of governance and enablement, running peer reviews, curating internal knowledge resources, hosting internal talks on idiomatic patterns, and serving as the first escalation path for teams encountering adoption friction. A guild helps ensure that governance's policy documents actually get translated into working practice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1oat7hk6l22w80u0ie70.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1oat7hk6l22w80u0ie70.jpg" alt="Image" width="594" height="742"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The governance model only works if every role has a named owner with explicit authority. Shared ownership of the architecture board agenda, or a guild with no dedicated time allocation in engineering capacity planning, produces the same outcome as no governance at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Architecture best practices&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;During a technological modernization transition, architectural governance needs to decide where the new stack or language is introduced and what rules govern the boundaries between legacy and modern components. Leaving these open leads to inconsistent integration across teams.&lt;/p&gt;

&lt;p&gt;The Architecture Board's module classification determines adoption order. Modules with high test coverage, well-defined service boundaries, and low coupling to legacy architecture are better candidates for early adoption than modules tightly woven into shared legacy infrastructure. New services and greenfield modules are natural starting points because they carry no migration overhead at boundaries. For existing systems, teams should establish clear API and domain boundaries before expanding adoption, decomposing tightly coupled components where necessary to reduce interop complexity.&lt;/p&gt;

&lt;p&gt;A comprehensive interoperability policy defines how legacy and modern code coexist. Explicit standards should govern type mapping, boundary annotations, and nullability or safety guarantees across the runtime boundary to prevent runtime errors and ensure seamless cross-language calling conventions.&lt;/p&gt;

&lt;p&gt;For public API surfaces, architects should inspect compiled representations and intermediate metadata prior to publishing. Conducting public API surface reviews catches boundary mismatches, unexpected dependency shapes, and compatibility issues before downstream consumer services depend on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Coding conventions and enforcement&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Coding standards require both a documented reference guide and an automated enforcement mechanism. Establishing clear organizational conventions covers naming schemes, source organization, formatting, and public API documentation standards to maintain maintainability across distributed teams.&lt;/p&gt;

&lt;p&gt;IDE-level enforcement provides real-time guidance to developers as they write code. Shared inspection and formatting profiles committed directly to version control automatically propagate standards across developer workspaces, flagging stylistic deviations prior to build or PR submission.&lt;/p&gt;

&lt;p&gt;Automated CI gatekeeping combines formatting checks and static analysis tools to maintain quality across builds. Static analysis rules should cover design complexity, code smells, and security risks. Using baseline configurations during initial adoption allows organizations to suppress pre-existing legacy issues while strictly gating new changes.&lt;/p&gt;

&lt;p&gt;In practice, the policy decision involves sorting violations into ones that block merges and ones that are just advisory. For example, in a Java-to-Kotlin transition, Ktlint's formatting violations block merges, as do high-severity Detekt rules like unused variables, null-safety bypasses, and excessive cyclomatic complexity. Lower-severity style suggestions are presented as advisory warnings and accumulate in the Detekt HTML report without blocking the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Enablement activities&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Governance structures and tooling create the conditions for successful adoption, but sustaining it needs some specific enablement activities.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;People and capability building&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;A governance framework that requires Kotlin without building Kotlin capability leads to resentment. Structured training tracks are the investment that makes the rest of the framework work.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.jetbrains.com/academy/" rel="noopener noreferrer"&gt;JetBrains Academy&lt;/a&gt; provides an in-IDE learning experience built around real coding tasks with instant feedback, structured into modular tracks that cover Kotlin fundamentals through advanced topics, including collections, coroutines, and testing. Integration with IntelliJ IDEA lets developers practice in the same environment they'll use for production work. For organizations that need credentialed outcomes, the &lt;a href="https://www.linkedin.com/learning/paths/kotlin-professional-certificate-by-jetbrains" rel="noopener noreferrer"&gt;Kotlin Professional Certificate by JetBrains&lt;/a&gt;, hosted on LinkedIn Learning, is an approximately 11-hour structured program covering Kotlin essentials, object-oriented and async code, and multiplatform development, with a final exam and a shareable certificate.&lt;/p&gt;

&lt;p&gt;Progression milestones are worth defining explicitly. For example, you could use a three-stage model that runs from Kotlin Reader (can understand and review Kotlin code), to Kotlin Contributor (can write idiomatic Kotlin in an existing codebase), to Kotlin Author (can design new Kotlin modules and mentor others). Pairing each stage with a concrete deliverable (a completed JetBrains Academy track, a reviewed pull request, an internal knowledge-sharing session) keeps the progression from being purely notional.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Code infrastructure and tooling&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Standardized build infrastructure prevents the configuration drift that makes cross-team work painful. Platform Engineering's responsibility here is to publish a Gradle convention plugin that encodes the organization's approved Kotlin version, the Detekt configuration with the agreed ruleset and baseline, the Ktlint version and .editorconfig settings, and the approved dependency management rules including any version catalog entries.&lt;/p&gt;

&lt;p&gt;Teams applying this plugin get compliant builds without reconstructing the configuration themselves. The version matrix (which Kotlin releases are approved, under evaluation, or deprecated) lives in a versioned document owned by Platform Engineering and reviewed on a defined cadence, such as quarterly. An internal support policy should make that cadence explicit, with upgrade reviews scheduled rather than triggered when someone notices a new release.&lt;/p&gt;

&lt;p&gt;An immediate action plan for teams starting this work follows a short sequence. First, classify your organization's existing codebases using a simple readiness matrix that covers test coverage, coupling to legacy internals, team familiarity with Kotlin, and strategic importance. Modules that score well on the first three and high on the fourth are your first adoption candidates. Second, draft the interop policy as a two-page document covering the annotation rules and platform-type handling described above. Third, stand up the Kotlin Guild with a founding group of volunteers before you need it (guilds that form in response to a crisis rarely build the community cohesion that makes them useful).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuv78y50xx8gqrsqo1ygv.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuv78y50xx8gqrsqo1ygv.jpg" alt="Image" width="794" height="59"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Bringing the framework together&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The five pillars of this framework (financial governance, ownership structure, architectural standards, coding conventions, and enablement activities) are individually straightforward. The difficulty is running them in parallel rather than sequentially, because each one creates conditions the others depend on.&lt;/p&gt;

&lt;p&gt;Financial governance gives the program political durability. Ownership structure gives it accountability. Architectural standards prevent the technical fragmentation that kills adoption at scale. Coding conventions and CI enforcement make sure those standards are applied in practice, not just aspirational. Enablement activities ensure the organization can execute what the governance structure requires.&lt;/p&gt;

&lt;p&gt;This framework draws on a field-tested, practical approach to enterprise tech stack modernization and Kotlin adoption. JetBrains tooling (from IntelliJ IDEA's built-in Code Style enforcement and bytecode inspection to the JetBrains Academy training tracks) is integrated directly into the governance infrastructure described here, reducing the friction of putting the framework into practice.&lt;/p&gt;

&lt;p&gt;Review the complete Enterprise Playbook for detailed guidance on enterprise stack modernization and Kotlin adoption at &lt;a href="https://kotlinlang.org/lp/enterprise-playbook/" rel="noopener noreferrer"&gt;kotlinlang.org/lp/enterprise-playbook/&lt;/a&gt;. Have you started classifying your organization's codebases for technology stack readiness? Share your approach in the comments.&lt;/p&gt;

</description>
      <category>kotlin</category>
      <category>modernization</category>
      <category>legacy</category>
      <category>enterprise</category>
    </item>
    <item>
      <title>Handling Auth in Next.js 16 with Server Actions and Middleware</title>
      <dc:creator>Aniket Bhattacharyea</dc:creator>
      <pubDate>Mon, 12 Oct 2026 10:44:27 +0000</pubDate>
      <link>https://dev.to/auth0/handling-auth-in-nextjs-16-with-server-actions-and-middleware-2gb5</link>
      <guid>https://dev.to/auth0/handling-auth-in-nextjs-16-with-server-actions-and-middleware-2gb5</guid>
      <description>&lt;p&gt;&lt;em&gt;TL;DR: Authentication in Next.js has changed significantly — patterns like client-side session hooks and middleware-only authorization can become insecure in a server-first application. Auth0's Next.js SDK v4 aligns with the App Router by replacing legacy route handlers, simplifying session access across server contexts, and supporting route protection.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Authentication in Next.js has changed significantly over the last few major releases. With Next.js 16, the framework fully embraces a server-first architecture: Server Components render on the server by default, Server Actions execute mutations server-side, and the App Router pushes developers toward colocated backend logic rather than traditional API-centric patterns.&lt;/p&gt;

&lt;p&gt;That shift changes how authentication should be implemented. Patterns that worked well in older Next.js applications, such as client-side session hooks, &lt;code&gt;/api/auth/*&lt;/code&gt; route handlers, and middleware-only authorization checks, can become awkward or even insecure in a server-first application. Session assumptions that were safe in client-heavy architectures no longer hold with Server Actions. Many developers upgrading existing applications discover that older Auth0 integration patterns simply no longer map cleanly to the App Router model.&lt;/p&gt;

&lt;p&gt;Instead of working around the framework, the latest &lt;a href="https://github.com/auth0/nextjs-auth0" rel="noopener noreferrer"&gt;Auth0 Next.js SDK&lt;/a&gt; is designed specifically for this architecture and integrates directly with the server-first model that Next.js 16 promotes. In this guide, you will learn how to build a production-grade authentication layer in a Next.js 16 App Router application using Auth0’s Next.js SDK v4. This article will cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The architectural shift from SDK v3 to v4&lt;/li&gt;
&lt;li&gt;How &lt;code&gt;proxy.ts&lt;/code&gt; replaces legacy auth route handlers&lt;/li&gt;
&lt;li&gt;Session access patterns in Server Components, Server Actions, and API routes&lt;/li&gt;
&lt;li&gt;Practical route protection strategies&lt;/li&gt;
&lt;li&gt;Security considerations that matter in &lt;a href="https://auth0.com/blog/auth0-native-integration-with-vercel/" rel="noopener noreferrer"&gt;real-world deployments&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Changed in Auth0 Next.js SDK v4 (and Why It Matters)
&lt;/h2&gt;

&lt;p&gt;Before App Router and Server Components became the default, authentication in Next.js typically revolved around API routes. In the Pages Router era, Auth0 integrations commonly looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// pages/api/auth/[auth0].ts&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;handleAuth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@auth0/nextjs-auth0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;handleAuth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Protected API routes used wrappers like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;withApiAuthRequired&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Session access relied on request/response objects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This model worked well because applications were heavily request-oriented. Rendering often happened client-side or through &lt;code&gt;getServerSideProps&lt;/code&gt;, and API routes formed the natural backend boundary. Next.js 16 changes the execution model entirely. Server Components run directly on the server without explicit request handlers. Server Actions execute independently from page renders, and route segments can stream progressively. The framework assumes that most logic lives server-side from the beginning.&lt;/p&gt;

&lt;p&gt;The diagram below shows how &lt;a href="https://github.com/auth0/nextjs-auth0" rel="noopener noreferrer"&gt;Auth0 SDK v4&lt;/a&gt; aligns directly with that architecture, tracing a request from initial HTTP call through to protected mutation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmvhw4gt05k4l8orytbxo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmvhw4gt05k4l8orytbxo.png" alt="An Incoming Request triggers proxy.ts, directing to app routes like Server Component. It intercepts auth routes via Auth0 SDK v4, creating an Encrypted Session Cookie. Server Component uses auth0.getSession(). Valid sessions show Protected UI Render, which enables submissions to Server Action, re-validating the cookie via getSession(). No session causes a redirect. Re-validation authorizes Protected Mutation or rejects the request." width="800" height="716"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Instead of manually defining &lt;code&gt;/api/auth/[auth0]&lt;/code&gt;, the SDK now auto-mounts authentication routes like &lt;code&gt;/auth/login&lt;/code&gt;, &lt;code&gt;/auth/logout&lt;/code&gt;, &lt;code&gt;/auth/profile&lt;/code&gt;, etc. when you include a properly configured &lt;code&gt;proxy.ts&lt;/code&gt; file (more on this file in the next section).&lt;/p&gt;

&lt;p&gt;The SDK also simplifies configuration substantially. For example, you can create a shared Auth0 client with only one line of code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/auth0.ts&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Auth0Client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@auth0/nextjs-auth0/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;auth0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Auth0Client&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDK automatically reads environment variables like &lt;code&gt;AUTH0_DOMAIN&lt;/code&gt;, &lt;code&gt;AUTH0_CLIENT_ID&lt;/code&gt;, &lt;code&gt;AUTH0_CLIENT_SECRET&lt;/code&gt;, &lt;code&gt;AUTH0_SECRET&lt;/code&gt;, &lt;code&gt;APP_BASE_URL&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This minimal, &lt;a href="https://auth0.com/blog/you-are-using-auth0-the-hard-way/" rel="noopener noreferrer"&gt;boilerplate-free setup&lt;/a&gt; is intentional. The SDK naturally integrates into the Next.js server-first runtime rather than requiring extensive bootstrapping code.&lt;/p&gt;

&lt;p&gt;The biggest practical change is session access. Instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;you now use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;auth0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with no request or response arguments.&lt;/p&gt;

&lt;p&gt;That works because the SDK reads encrypted session cookies directly from the current server execution context.&lt;/p&gt;

&lt;h2&gt;
  
  
  The proxy.ts Pattern
&lt;/h2&gt;

&lt;p&gt;The most important architectural concept in Auth0 SDK v4 is &lt;code&gt;proxy.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In Next.js 16, &lt;a href="https://nextjs.org/docs/app/getting-started/proxy" rel="noopener noreferrer"&gt;&lt;code&gt;proxy.ts&lt;/code&gt;&lt;/a&gt; acts as a request interceptor that executes before the framework processes a route. It replaces the former &lt;code&gt;middleware.ts&lt;/code&gt; convention, with the new name emphasizing that this code runs at the network boundary and can rewrite, redirect, or modify requests and responses before they reach your application routes. For Auth0, this interception point allows the SDK to handle authentication routes and session-related processing before the request continues into the application.&lt;/p&gt;

&lt;p&gt;Here is the standard implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/proxy.ts&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;auth0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./lib/auth0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;auth0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="cm"&gt;/*
     * Match all request paths except:
     * - _next/static
     * - _next/image
     * - favicon.ico
     * - sitemap.xml
     * - robots.txt
     */&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This file is the mechanism that enables the SDK to intercept and handle authentication routes automatically. Without it, requests to &lt;code&gt;/auth/login&lt;/code&gt; or &lt;code&gt;/auth/callback&lt;/code&gt; will typically return 404 errors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note: If your application uses a &lt;code&gt;src/&lt;/code&gt; directory, the &lt;code&gt;proxy.ts&lt;/code&gt; file must live inside this &lt;code&gt;src&lt;/code&gt; directory. Otherwise, it belongs at the project root. Misplacing the file often causes silent failures where auth routes return 404s even though the SDK appears configured correctly.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Understanding the matcher pattern
&lt;/h3&gt;

&lt;p&gt;The matcher pattern is more important than it initially appears.&lt;/p&gt;

&lt;p&gt;This line tells Next.js which requests should pass through the Auth0 proxy layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exclusions affect both performance and security. For example, paths like &lt;code&gt;_next/image&lt;/code&gt; serve optimized image requests, which should not be intercepted as this can introduce unnecessary auth processing overhead and break asset delivery. On the other hand, paths like &lt;code&gt;favicon.ico&lt;/code&gt; and &lt;code&gt;robots.txt&lt;/code&gt; are public assets that should not be protected behind authentication. You must also set up the matcher to exclude any other public routes in your app, but be careful not to accidentally exclude sensitive paths, as this may cause them to bypass auth interception entirely. This is why you should treat matcher configuration as part of your security model, not merely routing boilerplate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Auto-mounted routes
&lt;/h3&gt;

&lt;p&gt;Once &lt;code&gt;proxy.ts&lt;/code&gt; is configured, the SDK automatically mounts several auth endpoints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/auth/login&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/auth/logout&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/auth/callback&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/auth/profile&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/auth/access-token&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/auth0/nextjs-auth0#routes" rel="noopener noreferrer"&gt;and others&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These routes are now part of the SDK contract, and you no longer need to manually implement them yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Session Access in the Server with Components, Actions, and API Routes
&lt;/h2&gt;

&lt;p&gt;One of the most important concepts in Next.js 16 authentication is understanding that each server execution context behaves differently. Server Components, Server Actions, and API routes all run on the server, but session access patterns are not identical across them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Server Components
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://react.dev/reference/rsc/server-components" rel="noopener noreferrer"&gt;Server Components&lt;/a&gt; are the cleanest place to access session state. Because they execute directly on the server, they can read encrypted session cookies without any client-side round trip.&lt;/p&gt;

&lt;p&gt;Here is an example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/dashboard/page.tsx&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;redirect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;auth0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/auth0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DashboardPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;auth0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/auth/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;h1&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;Welcome&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/h1&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/main&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getSession()&lt;/code&gt; reads directly from encrypted cookies. If no session is found, the user is redirected. No API request is required, and no token exchange happens during rendering. The redirect occurs server-side before protected content renders. This is both simpler and more secure than relying on client-side auth checks after hydration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Server Actions
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://nextjs.org/docs/app/getting-started/mutating-data" rel="noopener noreferrer"&gt;Server Actions&lt;/a&gt; are where many developers accidentally introduce security gaps. Even though Server Actions execute on the server, they are invoked independently from the client. That means they cannot assume the session state from a previous page render is still valid, and every Server Action must re-validate authentication explicitly.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/actions/update-profile.ts&lt;/span&gt;

&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;auth0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/auth0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;auth0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unauthorized&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Protected mutation logic&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveUserProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some developers see this repeated session validation as redundant, but it is not. Server Actions are independent execution contexts. A user may have logged out, had their session invalidated, or switched accounts between the initial render and the action invocation. That is why you should treat every Server Action as a fresh authenticated request in order to avoid critical authorization bugs.&lt;/p&gt;

&lt;h3&gt;
  
  
  API routes
&lt;/h3&gt;

&lt;p&gt;For API routes, the SDK still provides a convenient protection wrapper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/admin/route.ts&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;auth0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/auth0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;auth0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withApiAuthRequired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is often the most straightforward approach for protected APIs because unauthenticated requests automatically receive a &lt;code&gt;401 Unauthorized&lt;/code&gt; response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accessing User Data and Tokens
&lt;/h2&gt;

&lt;p&gt;Once you have a session, you can access user information directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;auth0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you need to call a third-party API on behalf of the user, you can also retrieve the access token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;auth0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAccessToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Route Protection Strategies
&lt;/h2&gt;

&lt;p&gt;There is no single “correct” protection strategy in Next.js 16. The right approach depends on how much of your application is private and how centralized you want authorization logic to be.&lt;/p&gt;

&lt;h3&gt;
  
  
  Page-level protection
&lt;/h3&gt;

&lt;p&gt;The most explicit strategy is protecting individual pages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;auth0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/auth/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach gives maximum control and clarity since you can tweak the authorization logic for each page.&lt;/p&gt;

&lt;p&gt;Protecting individual pages works especially well when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only some routes are protected&lt;/li&gt;
&lt;li&gt;Authorization rules vary per page&lt;/li&gt;
&lt;li&gt;You need custom redirect logic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The downside is repetition at scale. Having this guard for every page can get too verbose.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layout-level protection
&lt;/h3&gt;

&lt;p&gt;For larger applications, layout-level protection is usually more maintainable.&lt;/p&gt;

&lt;p&gt;For example, this specifies that everything nested under &lt;code&gt;/dashboard&lt;/code&gt; now requires authentication automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/dashboard/layout.tsx&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;redirect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;auth0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/auth0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DashboardLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;auth0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/auth/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is often the best tradeoff for applications with clearly separated public and private route segments, such as a CMS with a public homepage and a dashboard for authenticated users.&lt;/p&gt;

&lt;h3&gt;
  
  
  proxy.ts-level protection
&lt;/h3&gt;

&lt;p&gt;For applications where nearly every route requires authentication, protection can move even higher into the request pipeline. You can extend the proxy matcher to blanket-protect large portions of the app. This reduces duplication and centralizes enforcement. However, it is usually less flexible than page or layout-level checks for applications that mix public marketing pages with authenticated dashboards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using useUser()
&lt;/h2&gt;

&lt;p&gt;So far, you have seen how we can authenticate the user on the server. But what about user profile access on the client-side? The SDK also provides a client-side hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;useUser()&lt;/code&gt; is useful for authentication and authorization in the UI, such as conditional UI rendering, user avatars, client-side personalization, navigation states, etc., but it should not be trusted for authorization decisions. Client-side state can be stale, manipulated, or temporarily inconsistent. For anything involving data access, mutations, permissions, or protected rendering, you should always validate using &lt;code&gt;getSession()&lt;/code&gt; on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Considerations
&lt;/h2&gt;

&lt;p&gt;The following are some of the most common security tips that you should keep in mind when using Auth0 with Next.js Server Actions and Middleware.&lt;/p&gt;

&lt;h3&gt;
  
  
  AUTH0_SECRET rotation
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;AUTH0_SECRET&lt;/code&gt; environment variable encrypts session cookies. If it changes, existing cookies can no longer be decrypted, resulting in errors like &lt;code&gt;JWEDecryptionFailed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In practice, users may need to clear cookies or re-authenticate after secret rotation. This becomes particularly important in CI/CD environments where secrets may accidentally differ across deployments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Allowed callback, logout, and origin URLs
&lt;/h3&gt;

&lt;p&gt;These &lt;a href="https://auth0.com/docs/get-started/applications/application-settings#application-uris" rel="noopener noreferrer"&gt;Auth0 dashboard settings&lt;/a&gt; are security controls, not just configuration details.&lt;/p&gt;

&lt;p&gt;Examples include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Allowed Callback URLs&lt;/li&gt;
&lt;li&gt;Allowed Logout URLs&lt;/li&gt;
&lt;li&gt;Allowed Web Origins&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These settings protect against malicious redirect flows and unauthorized origins. If they do not match your deployed application URLs exactly, authentication flows will fail intentionally.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do not trust client-side session state
&lt;/h3&gt;

&lt;p&gt;Authorization decisions should always happen server-side. This principle deserves repeating:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;useUser()&lt;/code&gt; is for UI&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;getSession()&lt;/code&gt; is for authorization in the server&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Relying on the client state for protected actions is one of the easiest ways to introduce privilege escalation bugs into Next.js applications.&lt;/p&gt;

&lt;h3&gt;
  
  
  Protect environment variables
&lt;/h3&gt;

&lt;p&gt;Secrets like &lt;code&gt;AUTH0_CLIENT_SECRET&lt;/code&gt; must never be exposed to the client bundle. Fortunately, Next.js only exposes variables prefixed with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; to client-side code. None of the Auth0 secrets use that prefix, so they are safe from being exposed by Next.js.&lt;/p&gt;

&lt;p&gt;However, they can still be accidentally made public. To prevent that, always store them in a &lt;code&gt;.env&lt;/code&gt; file, and never check it into version control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rethinking Auth in the Server Actions Era
&lt;/h2&gt;

&lt;p&gt;Authentication in Next.js 16 requires a different mindset than earlier generations of the framework. The App Router, Server Components, and Server Actions fundamentally change where code executes and how you trust boundaries should be enforced.&lt;/p&gt;

&lt;p&gt;In this guide, you learned how Auth0’s Next.js SDK v4 aligns with that architecture by replacing legacy route handlers with the &lt;code&gt;proxy.ts&lt;/code&gt; interception model, simplifying session access through server-native APIs, and supporting protection strategies that scale from individual pages to entire application segments. You also saw why session validation must happen independently across Server Components, Server Actions, and API routes, and why server-side authorization remains essential even in highly interactive React applications.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://auth0.com" rel="noopener noreferrer"&gt;Auth0&lt;/a&gt; is the identity platform behind all of this: developer-first, deeply flexible, and designed to scale from a weekend project to enterprise-grade infrastructure without forcing teams to trade control for convenience.&lt;/p&gt;

&lt;p&gt;The Next.js SDK v4 is a strong example of that philosophy in practice. Instead of layering abstractions on top of the framework, it embraces the Next.js server-first architecture directly through patterns like proxy.ts, server-native session access, and streamlined configuration. The result is an authentication model that feels natural inside modern Next.js applications while still supporting the advanced authorization, multi-tenant identity, and extensibility requirements that growing applications eventually need.&lt;/p&gt;

</description>
      <category>authentication</category>
      <category>backend</category>
      <category>nextjs</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Type-Safe Authentication in Rust with Auth0 and Axum</title>
      <dc:creator>Aniket Bhattacharyea</dc:creator>
      <pubDate>Mon, 12 Oct 2026 10:36:40 +0000</pubDate>
      <link>https://dev.to/auth0/type-safe-authentication-in-rust-with-auth0-and-axum-3eio</link>
      <guid>https://dev.to/auth0/type-safe-authentication-in-rust-with-auth0-and-axum-3eio</guid>
      <description>&lt;p&gt;CLI tools are deeply embedded in modern developer workflows. But while browser-based authentication is naturally integrated into web applications, and desktop and mobile applications work well with it, CLI applications present a unique challenge: there is no available browser or redirect URL to complete a standard OAuth login flow.&lt;/p&gt;

&lt;p&gt;This is exactly the problem the &lt;a href="https://auth0.com/docs/get-started/authentication-and-authorization-flow/device-authorization-flow" rel="noopener noreferrer"&gt;OAuth 2.0 Device Authorization Flow&lt;/a&gt; was designed to solve. Rather than redirecting users within the application, the CLI requests a temporary device code, displays a short verification URL and user code, and waits while the user authenticates in their browser. Once complete, the CLI receives tokens it can use for authenticated operations. You have likely encountered this flow before without realizing it. Smart TVs, IoT devices, and headless servers all rely on it when launching or redirecting to a browser is inconvenient or impossible.&lt;/p&gt;

&lt;p&gt;In this tutorial, you will build a complete end-to-end system in Rust and &lt;a href="https://github.com/tokio-rs/axum" rel="noopener noreferrer"&gt;Axum&lt;/a&gt; using Auth0. First, you will create a Rust CLI that authenticates users using the Auth0’s Device Authorization Flow and retrieves both an ID token and an access token. Then, you will build an Axum API to validate that access token before allowing access to a protected route. Together, these two pieces demonstrate the full authentication and authorization lifecycle: one application obtains trust, and another verifies it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;To follow along with this tutorial, you will need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An Auth0 account. You can &lt;a href="https://auth0.com/signup" rel="noopener noreferrer"&gt;sign up for free&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;The Rust toolchain installed on your system.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Configure Auth0
&lt;/h2&gt;

&lt;p&gt;Start by creating a new Native Application in the Auth0 dashboard.&lt;/p&gt;

&lt;p&gt;Open the Auth0 Dashboard, navigate to &lt;strong&gt;&lt;a href="https://manage.auth0.com/dashboard/#/applications" rel="noopener noreferrer"&gt;Applications &amp;gt; Applications&lt;/a&gt;&lt;/strong&gt;, and click &lt;strong&gt;Create Application&lt;/strong&gt;. Select &lt;strong&gt;Native&lt;/strong&gt; and give the application a name like &lt;code&gt;Rust CLI Demo&lt;/code&gt;, as you can see in the following screenshot.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc7vfq1tueo0ofdk2iz4t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc7vfq1tueo0ofdk2iz4t.png" alt="Create Application" width="800" height="778"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Native applications are designed specifically for desktop applications and CLI tools that cannot securely store a client secret.&lt;/p&gt;

&lt;p&gt;Once the application is created, open the &lt;strong&gt;Settings&lt;/strong&gt; tab, scroll to &lt;strong&gt;Advanced Settings &amp;gt; Grant Types&lt;/strong&gt;, and enable &lt;strong&gt;Device Code&lt;/strong&gt; as illustrated in the screenshot below.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5vfvzz5bwchcmm3f1y1f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5vfvzz5bwchcmm3f1y1f.png" alt="Advanced Settings" width="799" height="490"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This step is critical because Auth0 disables the Device Authorization grant by default. If you skip it, the token polling request will fail with an &lt;code&gt;unauthorized_client&lt;/code&gt; error that is difficult to diagnose if you do not already know the grant type is disabled.&lt;/p&gt;

&lt;p&gt;Now, save your changes.&lt;/p&gt;

&lt;p&gt;You will also need to copy the &lt;strong&gt;Domain&lt;/strong&gt; and &lt;strong&gt;Client ID&lt;/strong&gt; values from the Settings page. Store these somewhere safe, as you will need them later.&lt;/p&gt;

&lt;p&gt;Next, you will create an API in Auth0. This represents the secure API that you will access. Navigate to &lt;strong&gt;&lt;a href="https://manage.auth0.com/dashboard/#/apis" rel="noopener noreferrer"&gt;Applications &amp;gt; APIs&lt;/a&gt;&lt;/strong&gt;, click &lt;strong&gt;Create API&lt;/strong&gt;, give it a name, and set an identifier such as &lt;code&gt;https://rust-api-demo.example.com&lt;/code&gt;, as shown in the following screenshot.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2v2i344l8ywrs7a72v14.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2v2i344l8ywrs7a72v14.png" alt="General Settings" width="800" height="330"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This API identifier becomes the &lt;strong&gt;audience&lt;/strong&gt; for your access tokens.&lt;/p&gt;

&lt;p&gt;Now, you need to give your application access to the API. Go to &lt;strong&gt;Application Access&lt;/strong&gt; and click &lt;strong&gt;Edit&lt;/strong&gt; next to the name of your application. In the &lt;strong&gt;User-Delegated Access&lt;/strong&gt; tab, click &lt;strong&gt;Grant Access&lt;/strong&gt;. Your screen should look like the screenshot below.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx8f6wwz5fcll3oe43zp6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx8f6wwz5fcll3oe43zp6.png" alt="Rust API Application Access" width="800" height="223"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Create a CLI in Rust
&lt;/h2&gt;

&lt;p&gt;With Auth0 configured, you can now start writing the Rust code. The first thing you need to create is a Rust CLI that uses the Device Authorization Flow to authenticate with Auth0.&lt;/p&gt;

&lt;p&gt;Create a new Rust workspace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;rust-auth0-demo
&lt;span class="nb"&gt;cd &lt;/span&gt;rust-auth0-demo

cargo new auth-lib &lt;span class="nt"&gt;--lib&lt;/span&gt;
cargo new auth0-cli
cargo new auth0-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These commands create a &lt;code&gt;rust-auth0-demo&lt;/code&gt; directory and three Rust projects within it.&lt;/p&gt;

&lt;p&gt;Configure the workspace root &lt;code&gt;Cargo.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[workspace]&lt;/span&gt;
&lt;span class="py"&gt;resolver&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"2"&lt;/span&gt;

&lt;span class="py"&gt;members&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s"&gt;"auth-lib"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"auth0-cli"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"auth0-api"&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Build the Shared Authentication Library
&lt;/h2&gt;

&lt;p&gt;The token verification logic lives in the shared &lt;code&gt;auth-lib&lt;/code&gt; library crate used by both the CLI and API.&lt;/p&gt;

&lt;p&gt;Inside &lt;code&gt;auth-lib/Cargo.toml&lt;/code&gt;, add the following dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[dependencies]&lt;/span&gt;
&lt;span class="py"&gt;anyhow&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0.102"&lt;/span&gt;
&lt;span class="py"&gt;jsonwebtoken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"10.4.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"rust_crypto"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;reqwest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.13.4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"form"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"json"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;serde&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0.228"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"derive"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;serde_json&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0.150"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These dependencies each serve particular functions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;anyhow&lt;/code&gt;: Simplifies error handling and propagation across the application.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;jsonwebtoken&lt;/code&gt;: Validates and decodes JWTs using Rust-based cryptographic verification.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;reqwest&lt;/code&gt;: Sends HTTP requests to Auth0 APIs with form and JSON support.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;serde&lt;/code&gt;: Serializes and deserializes Rust structs to and from JSON.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;serde_json&lt;/code&gt;: Provides JSON parsing, generation, and the &lt;code&gt;json!&lt;/code&gt; macro for structured responses.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Create the following structure in the &lt;code&gt;auth-lib&lt;/code&gt; directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;auth-lib/
├── src/
│   ├── lib.rs
│   ├── auth.rs
│   └── models.rs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Define typed models
&lt;/h3&gt;

&lt;p&gt;Inside &lt;code&gt;auth-lib/src/models.rs&lt;/code&gt;, define the data models, which eliminate entire categories of bugs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;serde&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Serialize&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;DeviceCodeResponse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;device_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;user_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;verification_uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;verification_uri_complete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;expires_in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;TokenResponse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;id_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;expires_in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;token_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;TokenError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;error_description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Serialize,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize)]&lt;/span&gt;
&lt;span class="nd"&gt;#[serde(untagged)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;Audience&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;Single&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;Multiple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Serialize,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;UserClaims&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;iss&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Audience&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Jwk&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;kid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;#[derive(Debug,&lt;/span&gt; &lt;span class="nd"&gt;Deserialize)]&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;Jwks&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Jwk&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without these typed structs, authentication code often becomes fragile JSON parsing logic with unchecked assumptions about response fields. Here, every expected field is explicit and validated during deserialization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implement JWT Validation
&lt;/h2&gt;

&lt;p&gt;Inside &lt;code&gt;auth-lib/src/auth.rs&lt;/code&gt;, add the logic that verifies the JWT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;anyhow&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;jsonwebtoken&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;
    &lt;span class="n"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;decode_header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Algorithm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;DecodingKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Validation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;reqwest&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="k"&gt;crate&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;models&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="n"&gt;Jwks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UserClaims&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;DOMAIN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"YOUR_DOMAIN"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;validate_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;UserClaims&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="c1"&gt;// Decode JWT header&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decode_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;kid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;
        &lt;span class="py"&gt;.kid&lt;/span&gt;
        &lt;span class="nf"&gt;.ok_or_else&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="nn"&gt;anyhow&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;anyhow!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Missing kid"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Fetch JWKS&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;jwks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Jwks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;
        &lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"https://{}/.well-known/jwks.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;DOMAIN&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
        &lt;span class="nf"&gt;.json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Find matching key&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;jwk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwks&lt;/span&gt;
        &lt;span class="py"&gt;.keys&lt;/span&gt;
        &lt;span class="nf"&gt;.into_iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.find&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="py"&gt;.kid&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;kid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.ok_or_else&lt;/span&gt;&lt;span class="p"&gt;(||&lt;/span&gt; &lt;span class="nn"&gt;anyhow&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;anyhow!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"No matching JWK"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Build RSA decoding key&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;decoding_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;DecodingKey&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_rsa_components&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;jwk&lt;/span&gt;&lt;span class="py"&gt;.n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;jwk&lt;/span&gt;&lt;span class="py"&gt;.e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Configure validation&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;validation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nn"&gt;Validation&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nn"&gt;Algorithm&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;RS256&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;validation&lt;/span&gt;&lt;span class="nf"&gt;.set_audience&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="n"&gt;validation&lt;/span&gt;&lt;span class="nf"&gt;.set_issuer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://{}/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DOMAIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="c1"&gt;// Decode and validate&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;token_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;UserClaims&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;decoding_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;validation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token_data&lt;/span&gt;&lt;span class="py"&gt;.claims&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;validate_token&lt;/code&gt; function verifies that a JWT was genuinely issued by Auth0 and has not been tampered with. It fetches Auth0’s public signing keys (JWKS), selects the correct key using the token’s &lt;code&gt;kid&lt;/code&gt;, validates the token signature and claims such as issuer and audience, and finally deserializes the payload into a strongly typed &lt;code&gt;UserClaims&lt;/code&gt; struct.&lt;/p&gt;

&lt;p&gt;Here, you can again see Rust’s strong typing in action. The deserialization into the &lt;code&gt;UserClaims&lt;/code&gt; struct guarantees the presence of all the required fields. If any field is absent, you get a hard error instead of silently failing parsing.&lt;/p&gt;

&lt;p&gt;Finally, export the modules in &lt;code&gt;auth-lib/src/lib.rs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Build the CLI and Device Authorization Flow
&lt;/h2&gt;

&lt;p&gt;To start building the CLI and device authorization flow, first, move into the CLI project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;auth0-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the following dependencies in &lt;code&gt;Cargo.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[dependencies]&lt;/span&gt;
&lt;span class="py"&gt;anyhow&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0.102"&lt;/span&gt;
&lt;span class="py"&gt;jsonwebtoken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"10.4.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"rust_crypto"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;reqwest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.13.4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"form"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"json"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;serde&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0.228"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"derive"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;serde_json&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.0.150"&lt;/span&gt;
&lt;span class="py"&gt;tokio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1.52.3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"full"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;auth-lib&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"../auth-lib"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Request the device code
&lt;/h3&gt;

&lt;p&gt;Before you can add a function to request the device code, which is the first step in the authorization flow, you need to add the following imports and variables inside &lt;code&gt;auth0-cli/src/main.rs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;anyhow&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;auth_lib&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;
    &lt;span class="nn"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;validate_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nn"&gt;models&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;
        &lt;span class="n"&gt;DeviceCodeResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;TokenError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;TokenResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;DOMAIN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"YOUR_DOMAIN"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"YOUR_CLIENT_ID"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;AUDIENCE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"YOUR_AUDIENCE"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Note: This code hardcodes the domain, client ID, and audience values for simplicity. These values are not sensitive and can be safely stored in code. For more flexibility or to store sensitive secrets, you can use a crate like &lt;a href="https://crates.io/crates/dotenv" rel="noopener noreferrer"&gt;dotenv&lt;/a&gt; to store values as environment variables.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Make sure you replace the placeholder values with the data from Auth0.&lt;/p&gt;

&lt;p&gt;Add the &lt;code&gt;request_device_code()&lt;/code&gt; function in &lt;code&gt;main.rs&lt;/code&gt; to request the device code and start the Device Authorization Flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;request_device_code&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DeviceCodeResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;reqwest&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;
        &lt;span class="nf"&gt;.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"https://{}/oauth/device/code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;DOMAIN&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="nf"&gt;.form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"client_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"openid profile email"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"audience"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AUDIENCE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;device_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;
        &lt;span class="py"&gt;.json&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DeviceCodeResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;device_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This endpoint makes a request to &lt;code&gt;/oauth/device/code&lt;/code&gt; in your Auth0 domain with the client ID, scope, and audience data. Auth0 returns a short-lived device code along with instructions for the user.&lt;/p&gt;

&lt;h3&gt;
  
  
  Poll for the token
&lt;/h3&gt;

&lt;p&gt;Once the code is retrieved, the next step is to show the user the code and the login URL. Since a CLI has no redirect URL or callback mechanism, the only way to know when the authorization is finished is to poll for the status in regular intervals.&lt;/p&gt;

&lt;p&gt;Add the following code in &lt;code&gt;main.rs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;poll_for_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;device_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TokenResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;reqwest&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;loop&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;
            &lt;span class="nf"&gt;.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"https://{}/oauth/token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;DOMAIN&lt;/span&gt;
            &lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="nf"&gt;.form&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="s"&gt;"grant_type"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"urn:ietf:params:oauth:grant-type:device_code"&lt;/span&gt;
                &lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"device_code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device_code&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"client_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="nf"&gt;.status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.is_success&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;response&lt;/span&gt;
                    &lt;span class="py"&gt;.json&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TokenResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
            &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;
            &lt;span class="py"&gt;.json&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TokenError&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="py"&gt;.error&lt;/span&gt;&lt;span class="nf"&gt;.as_str&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

            &lt;span class="s"&gt;"authorization_pending"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Waiting for authentication..."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="s"&gt;"slow_down"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Slowing polling interval..."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="s"&gt;"expired_token"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nn"&gt;anyhow&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;bail!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Device code expired"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="s"&gt;"access_denied"&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nn"&gt;anyhow&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;bail!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"User denied access"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="n"&gt;other&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nn"&gt;anyhow&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nd"&gt;bail!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="s"&gt;"Unhandled error: {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;other&lt;/span&gt;
                &lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_secs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;poll_for_token&lt;/code&gt; function repeatedly checks Auth0’s token endpoint to see whether the user has completed authentication in the browser. This function:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sends the device code obtained earlier&lt;/li&gt;
&lt;li&gt;Handles all possible OAuth device authorization flow responses such as &lt;code&gt;authorization_pending&lt;/code&gt;, &lt;code&gt;slow_down&lt;/code&gt;, and &lt;code&gt;access_denied&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;Waits between polling attempts using Tokio’s async sleep&lt;/li&gt;
&lt;li&gt;Returns the access and ID tokens once authentication succeeds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once again, the strong typing ensures that the JSON has all the required fields.&lt;/p&gt;

&lt;p&gt;OAuth device authorization flow responses are inherently asynchronous, and Rust forces you to consciously handle every possible outcome using the &lt;code&gt;match&lt;/code&gt; statement instead of silently ignoring error states.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wire the two functions
&lt;/h3&gt;

&lt;p&gt;Finally, wire these two functions together in the main function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[tokio::main]&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;request_device_code&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"Visit:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;{}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="py"&gt;.verification_uri_complete&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"Code: {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="py"&gt;.user_code&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;poll_for_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="py"&gt;.device_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="py"&gt;.interval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Access Token: {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="py"&gt;.access_token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;main&lt;/code&gt; function calls the &lt;code&gt;request_device_code&lt;/code&gt; function, prints the verification URL and the device code, and calls the &lt;code&gt;poll_for_token&lt;/code&gt; function. The access token returned from this function is printed.&lt;/p&gt;

&lt;p&gt;You can run the CLI by executing &lt;code&gt;cargo run&lt;/code&gt;. You should see an output like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Visit:
https://your-tenant.auth0.com/activate?user_code=ABCD-EFGH
Code: ABCD-EFGH
Waiting for authentication...
Waiting for authentication...
Waiting for authentication...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you visit the URL in your browser, you will be prompted to log in with Auth0. You can use an existing Auth0 account to log in or create a new account. Once you log in, Auth0 generates a token, which your code will automatically fetch. You will see the access token printed in the terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Access Token: eyJhbGciOiJSUzI1NiIsIn....
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that this example prints the access token for demo purposes only. In a real-world application, you should never log the access token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fetch the Authenticated User
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;token&lt;/code&gt; returned from the &lt;code&gt;poll_for_token&lt;/code&gt; function also includes an ID token, which contains information about the user's identity. The CLI can validate the &lt;strong&gt;ID token&lt;/strong&gt; and extract information about the user, like name or email.&lt;/p&gt;

&lt;p&gt;Add the following function in the same file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;say_hi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nn"&gt;auth_lib&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;models&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;UserClaims&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;
        &lt;span class="py"&gt;.name&lt;/span&gt;
        &lt;span class="nf"&gt;.as_deref&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.unwrap_or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Welcome, {}!"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This function extracts the &lt;code&gt;name&lt;/code&gt; field from a &lt;code&gt;UserClaims&lt;/code&gt; object and prints a greeting.&lt;/p&gt;

&lt;p&gt;You can also fetch the user info from the &lt;code&gt;/userinfo&lt;/code&gt; &lt;a href="https://auth0.com/docs/api/authentication/user-profile/get-user-info" rel="noopener noreferrer"&gt;endpoint&lt;/a&gt; by passing the access token.&lt;/p&gt;

&lt;p&gt;Change the &lt;code&gt;main&lt;/code&gt; function to call the &lt;code&gt;validate_token&lt;/code&gt; function and pass the returned claims to &lt;code&gt;say_hi&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[tokio::main]&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nf"&gt;request_device_code&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"Visit:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;{}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="py"&gt;.verification_uri_complete&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"Code: {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="py"&gt;.user_code&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;poll_for_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="py"&gt;.device_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="py"&gt;.interval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validate_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="py"&gt;.id_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="nf"&gt;say_hi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"Access token:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;{}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="py"&gt;.access_token&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, when you run it, you will see the user's name being printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Visit:
https://your-tenant.auth0.com/activate?user_code=ABCD-EFGH
Code: ABCD-EFGH
Waiting for authentication...
Waiting for authentication...

Welcome, Alice!

Access token: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this stage, you have a fully authenticated CLI application using Auth0’s Device Authorization Flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Protect an Axum API Route with Auth0
&lt;/h2&gt;

&lt;p&gt;Now that the CLI can retrieve an access token, the next step is building a protected API accessible through it.&lt;/p&gt;

&lt;p&gt;Move into the API project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ../auth0-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add dependencies to &lt;code&gt;Cargo.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[dependencies]&lt;/span&gt;
&lt;span class="py"&gt;auth-lib&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"../auth-lib"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="py"&gt;axum&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.8"&lt;/span&gt;
&lt;span class="py"&gt;tokio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;features&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"full"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="py"&gt;serde_json&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Axum is a modern asynchronous web framework for Rust built on top of Tokio and Hyper. With its simple and modular design, Axum makes it easy to route requests to handlers with a macro-free API and generate responses with minimal boilerplate. This tutorial uses Axum to create a protected API route that validates Auth0 access tokens before allowing access to authenticated users.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create a JWT extractor
&lt;/h3&gt;

&lt;p&gt;The first step in protecting your API route is creating a JWT extractor middleware that reads the &lt;code&gt;Authorization&lt;/code&gt; header, extracts the Bearer token, validates the JWT, and rejects invalid tokens with &lt;code&gt;401 Unauthorized&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Inside &lt;code&gt;auth0-api/src/main.rs&lt;/code&gt;, add the following code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;axum&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;
    &lt;span class="nn"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;FromRequestParts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nn"&gt;http&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;&lt;span class="nn"&gt;request&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Parts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nn"&gt;routing&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;serde_json&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nn"&gt;auth_lib&lt;/span&gt;&lt;span class="p"&gt;::{&lt;/span&gt;
    &lt;span class="nn"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;validate_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nn"&gt;models&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;UserClaims&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;AUDIENCE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"YOUR_AUDIENCE"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="nf"&gt;AuthUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;UserClaims&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;impl&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;FromRequestParts&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;AuthUser&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt;
    &lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Send&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Sync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Rejection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Json&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;serde_json&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;from_request_parts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;Parts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;_state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Rejection&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;auth_header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;
            &lt;span class="py"&gt;.headers&lt;/span&gt;
            &lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.and_then&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="nf"&gt;.to_str&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.ok&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="nf"&gt;.ok_or&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;
                &lt;span class="nn"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;UNAUTHORIZED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nf"&gt;Json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;json!&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                    &lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="s"&gt;"Missing authorization header"&lt;/span&gt;
                &lt;span class="p"&gt;})),&lt;/span&gt;
            &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth_header&lt;/span&gt;
            &lt;span class="nf"&gt;.strip_prefix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Bearer "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;.ok_or&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;
                &lt;span class="nn"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;UNAUTHORIZED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nf"&gt;Json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;json!&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                    &lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="s"&gt;"Invalid bearer token"&lt;/span&gt;
                &lt;span class="p"&gt;})),&lt;/span&gt;
            &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validate_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;AUDIENCE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;
        &lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nn"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;UNAUTHORIZED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nf"&gt;Json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;json!&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                &lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Invalid token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"details"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="p"&gt;})),&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;AuthUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This middleware centralizes authentication logic so that protected routes only execute if validation succeeds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create the protected route
&lt;/h3&gt;

&lt;p&gt;The API validates the &lt;strong&gt;access token&lt;/strong&gt;. If the token is valid, a message is shown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;protected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;AuthUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;AuthUser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Json&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nn"&gt;serde_json&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="nf"&gt;Json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;json!&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="s"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="s"&gt;"You are seeing a protected route"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

        &lt;span class="s"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="py"&gt;.sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the &lt;code&gt;main&lt;/code&gt; function to set up the router:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="nd"&gt;#[tokio::main]&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/protected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;protected&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;listener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;net&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;TcpListener&lt;/span&gt;
        &lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"0.0.0.0:3000"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;
        &lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Server listening on port 3000"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nn"&gt;axum&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;
        &lt;span class="nf"&gt;.unwrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Call the API from the CLI
&lt;/h3&gt;

&lt;p&gt;The final step of the whole flow is for the CLI to call the API and pass the access token in the &lt;code&gt;Authorization&lt;/code&gt; header. Add the following function in &lt;code&gt;auth0-cli/src/main.rs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;call_api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nn"&gt;anyhow&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;reqwest&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;
        &lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://localhost:3000/protected"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.bearer_auth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.send&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="nf"&gt;.status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.is_success&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;serde_json&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="nf"&gt;.json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"API response:"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"{}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nn"&gt;serde_json&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;to_string_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"API returned {}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="nf"&gt;.status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="nf"&gt;.text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nd"&gt;println!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This code calls the protected route you created in the previous step, passes the access token, and prints the response in the terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the Full Flow
&lt;/h2&gt;

&lt;p&gt;To test the flow, first, run the API server from the &lt;code&gt;auth0-api&lt;/code&gt; directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cargo run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Authenticate using the CLI as before. The CLI will call the API, and you should see the success response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"You are seeing a protected route"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"google-oauth2|123456789"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can find the full app on &lt;a href="https://github.com/heraldofsolace/rust-auth0-demo" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rust, Auth0, and Axum Bring Type-Safe Authentication to Your CLI
&lt;/h2&gt;

&lt;p&gt;In this tutorial, you built a complete authentication flow in Rust using Auth0 and Axum. The CLI used Auth0’s Device Authorization Flow to securely authenticate users in a browserless environment, while the Axum API validated the resulting access token before allowing access to protected endpoints. Rust’s type system plays an important role throughout the implementation, with typed API responses preventing malformed OAuth responses from slipping through silently.&lt;/p&gt;

&lt;p&gt;From here, you could add refresh token support to keep the CLI authenticated across sessions, extend the Axum server with role-based authorization using Auth0 custom claims, or &lt;a href="https://openfga.dev/" rel="noopener noreferrer"&gt;explore fine-grained authorization&lt;/a&gt; for relationship-based access control.&lt;/p&gt;

&lt;p&gt;The same Auth0 platform that powers this example also scales to MFA, enterprise SSO, and multi-tenant B2B identity as your needs grow. Check out the &lt;a href="https://auth0.com/docs" rel="noopener noreferrer"&gt;Auth0 documentation&lt;/a&gt; and the &lt;a href="https://auth0.com/docs/get-started/authentication-and-authorization-flow/device-authorization-flow#device-authorization-flow" rel="noopener noreferrer"&gt;Device Authorization Flow guide&lt;/a&gt; to keep building.&lt;/p&gt;

</description>
      <category>auth0</category>
      <category>rust</category>
      <category>authentication</category>
    </item>
    <item>
      <title>How to Be the Best Tutorial Writer in the Room</title>
      <dc:creator>Aniket Bhattacharyea</dc:creator>
      <pubDate>Wed, 26 Aug 2026 12:34:57 +0000</pubDate>
      <link>https://dev.to/heraldofsolace/how-to-be-the-best-tutorial-writer-in-the-room-1hb7</link>
      <guid>https://dev.to/heraldofsolace/how-to-be-the-best-tutorial-writer-in-the-room-1hb7</guid>
      <description>&lt;p&gt;There's a special kind of pain reserved for people who follow a tutorial at 11 PM and hit a wall on step 4 because the author renamed a variable between step 3 and step 5 and never told you. You stare at your terminal. The error makes no sense. You check the code four times. Eventually you realize the tutorial was never run, start to finish, by a human being — it was assembled from memory, vibes, and whatever the author thought they did six months ago.&lt;/p&gt;

&lt;p&gt;I don't want to be that person, and after enough tutorials spanning across clients like Docker, Auth0, JetBrains, and others, I've settled on a short list of things that actually separate a tutorial that works from one that just looks like it does. None of this is exotic. It's mostly discipline, applied at the right moments, and a healthy suspicion of your own memory.&lt;/p&gt;

&lt;p&gt;I'm not going to talk about the same old boring advices like "Improve your English, use your own language." I myself am not a native English speaker, and I write very simple sentences -- no flowery language, no complicated prose -- and I can tell you that no client has ever questioned me about that. I'm going to give you some real advice, battle tested with real clients.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; All of the points below are my own advice, learned from my experience with &lt;a href="https://draft.dev" rel="noopener noreferrer"&gt;Draft.dev&lt;/a&gt;. These aren't absolute gospels, and you shouldn't take them as such.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Clearly Define the Article Requirement
&lt;/h2&gt;

&lt;p&gt;Before you write a single sentence, know what the article is actually supposed to deliver. Not the topic — the &lt;em&gt;shape&lt;/em&gt;. Is this a 500-word "here's a neat trick" post, or a 3,000-word soup-to-nuts build? Does it stand alone, or does it assume the reader already has your last three articles' worth of setup done? Is the deliverable a working demo app, or is the demo just scaffolding to explain one specific concept?&lt;/p&gt;

&lt;p&gt;I cannot stress enough the advantages of creating an outline before you start writing. I prefer to collect all the information I can from my clients, including details about their company, their product, and the topic, and create an outline with it. An outline gives you the basic shape of the article and tells you what to do, and what not to do.&lt;/p&gt;

&lt;p&gt;You can download a copy of the "content plan" template I use with my clients &lt;a href="https://5c50039a.sibforms.com/serve/MUIFADrkYEMLnNnH_XqL4flqF5UtouWdBbYHNLysLADPyIBfCc9nkYpHOC0ZDWJ6BPNHfgZUXJnE-5f6QMmMn8BZG6ckTPAVAyYQx9jqQq1VMgzapkch_3fPGd1XNKbtIooX-g1peVHBI0e39XC3RA_n-cyT3GbP6-ICIf_kFGqUOveW6UR5ulka5N-DxykCdmKps3c43B-Q10pl" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Create a Compelling Scenario
&lt;/h2&gt;

&lt;p&gt;Once you know the requirement and before you touch the audience question, decide what the demo app is actually &lt;em&gt;about&lt;/em&gt;. This is where a lot of otherwise-solid tutorials go flat, because the author reaches for the most generic possible container for the feature they're demonstrating.&lt;/p&gt;

&lt;p&gt;Say you're writing a tutorial about authentication and authorization. The lazy version of the prompt to yourself is "let's create a basic app and add authn and authz." Technically accurate, and it produces exactly what it promises: a to-do list or a "hello, dashboard" page with a login screen bolted onto it, and a role check somewhere that simply prints your roles on an HTML page. Nobody remembers that tutorial a week later, because there was nothing to remember.&lt;/p&gt;

&lt;p&gt;Compare that to: "let's build a CMS where anyone can sign up, write and publish articles, but can't delete someone else's article." Same underlying feature -- authn and authz -- but now it's a scenario, not a checklist item, and the scenario does several things the generic version can't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It forces you to define real roles before you write any code&lt;/strong&gt;, because "someone else's article" only means something once you've decided who owns what. That's authorization design, not authorization theater.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It gives you a built-in negative test case for free.&lt;/strong&gt; "User B tries to delete User A's article and gets blocked" is a genuinely satisfying moment to demonstrate -- readers get to &lt;em&gt;see&lt;/em&gt; the authorization actually stop something, instead of trusting your word that the middleware is wired up correctly. A generic "protected route" demo never gets to prove a negative like that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It reads well in a title and in a search result.&lt;/strong&gt; "How to Build a CMS with Role-Based Permissions" gets clicked. "How to Add Authn and Authz to a Basic App" reads like a boring text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It gives your screenshots something to be &lt;em&gt;of&lt;/em&gt;.&lt;/strong&gt; A login form looks like every other login form ever screenshotted. An article list with a greyed-out delete button next to someone else's post is an image that actually explains the feature at a glance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A compelling scenario is really the requirement and the audience decisions from the last two points, made concrete enough to build. If your scenario is interesting enough, half of your outline writes itself, because the scenario itself implies the steps: sign-up, write a post, publish it, try to break it as someone else. Boring scenarios don't do that work for you -- you end up having to invent steps to pad the tutorial out, and padding is always the first thing a reader notices.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note: A compelling scenario doesn't have to be elaborate. It just has to be specific enough that a stranger, reading only the title, already has a rough guess of what the finished thing does. Don't get carried away and try to make it too complex.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Clearly Define the Audience
&lt;/h2&gt;

&lt;p&gt;This ties to the previous point as this is part of the outline for me, but it's so important that this requires its own paragraph. This is the single biggest lever on how much detail you show, and it's the one people skip because they think they already know. "Developers" is not an audience. "Someone who's shipped a REST API before but has never touched OAuth" is an audience.&lt;/p&gt;

&lt;p&gt;Audience determines everything downstream: whether you explain what a JWT is or just link to a reference, whether you spell out &lt;code&gt;npm install&lt;/code&gt; or assume it, whether you show the full error message or just say "if you see an auth error, check your redirect URI." Undershoot the audience and you lose beginners on page one. Overshoot it and your expert reader skims past three paragraphs of &lt;code&gt;npm install&lt;/code&gt; ceremony looking for the one paragraph that matters, and maybe doesn't find your article again next time.&lt;/p&gt;

&lt;p&gt;I write a one-line audience note to myself before drafting — something like &lt;em&gt;"assumes React familiarity, zero assumption of Next.js server actions"&lt;/em&gt; — and I keep it visible while writing. Every time I'm about to explain (or skip explaining) something, I check it against that line instead of against my own head, because my own head thinks everything is obvious by the third read-through.&lt;/p&gt;

&lt;p&gt;Here's a concrete example of how audience shapes the article details for me. Imagine I am teaching how to create a Next.js app. If my audience is a beginner who has zero to minimal Next.js experience, I'll start with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Run the following command to create a Next.js app:

npx create-next-app@latest my-app --yes

Now move into the directory by running "cd my-app" and start the dev server by running "npm run dev"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But if my audience is intermediate or expert in Next.js, I'll write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Create a Next.js app and start it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Note: There is one situation where I'll spell out the details irrespective of the audience. That is, if I'm doing something that is not the default. For example, I'm using a different version of a tool, or passing a non-default option to the command.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Build the App First, to Know It Works
&lt;/h2&gt;

&lt;p&gt;This should not need saying, and yet. Before you write a word of prose, build the actual thing, end to end, on your machine, from a clean slate if you can manage it. Not "I did something like this six months ago and it's basically the same." Build it now with the versions of the tools that exist.&lt;/p&gt;

&lt;p&gt;The reason this matters isn't pride — it's that software drifts under you while you're not looking. An SDK bumps a major version and renames a constructor argument. A framework moves from pages to app router. A CLI flag gets deprecated with a warning nobody reads. If your mental model of "how this works" is even a few months stale, you will confidently write down something that used to be true.&lt;/p&gt;

&lt;p&gt;This happened to me on my own side project, actually — not an article, but close enough to sting the same way. I stubbed an external client in my tests, the tests passed, and I moved on trusting that green checkmark. Then I ran the app for real and discovered that the library had changed its constructor from keyword arguments to positional ones between the version I remembered and the installed version. My stub accepted anything, so nothing caught it. The lesson transfers directly to writing: a test (or an article) that was never run against the real thing isn't verification; it's a nice story you're telling yourself.&lt;/p&gt;

&lt;p&gt;This is also useful when you're writing about a client's product. Many clients -- typically young startups -- don't pay as much attention to their documentation as they do to their marketing. Often, their docs will say "do X to get Y," but when you actually try it, you'll discover it doesn't work. Either the feature doesn't exist, but the Sales team got excited and claimed boldly that it's available, or that the docs were hallucinated by an AI, or maybe it worked at some point, but something changed, and nobody updated the docs.&lt;/p&gt;

&lt;p&gt;That's why build it first. Confirm it works. &lt;em&gt;Then&lt;/em&gt; start writing about it. As you build the app, try to develop a mental model of the steps you will show in the article, in what order. Building the app will tell you everything you need to do to get it working. You can write it down or memorize it. Either way, you now have a solid foundation of what works and what doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Build the App Once More as You Write
&lt;/h2&gt;

&lt;p&gt;Here's the part people skip because they just did step 4, so surely they remember what happened. You don't, not precisely enough. The gap between "I built this, and it worked" and "I wrote down, in order, the exact commands and code that make it work" is wider than it feels.&lt;/p&gt;

&lt;p&gt;As you write each step, actually perform that step again, right then, and paste in what genuinely happened — not what you remember happening. This catches the stuff that ruins tutorials: the config value you tweaked halfway through and forgot to write down, the folder you created out of order, the environment variable you set in one terminal tab and never mentioned. Writing and rebuilding in lockstep turns your article into a transcript of something real rather than a summary of something remembered.&lt;/p&gt;

&lt;p&gt;Building the app as you write the article will also let you capture screenshots and outputs immediately, making the article feel more alive. More on this later.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Build the App One More Time as You Go Through the Final Copy
&lt;/h2&gt;

&lt;p&gt;By the time you're proofreading, you're reading your own writing, and your own writing is exactly the thing you're least equipped to catch errors in — you know what you &lt;em&gt;meant&lt;/em&gt;, so your eyes helpfully autocorrect what you &lt;em&gt;wrote&lt;/em&gt;. A missing &lt;code&gt;cd&lt;/code&gt; into the right directory, a code block that's missing the import you added at the last minute, a step that got reordered when you moved a paragraph for flow and never noticed the code above it now depends on something that hasn't happened yet.&lt;/p&gt;

&lt;p&gt;The fix is unglamorous: go through the final copy one more time, but instead of just reading it, &lt;em&gt;execute it&lt;/em&gt;, literally, in a fresh terminal, following your own instructions exactly as written rather than as intended. Not "run something like this" — copy the actual commands out of your own article and run those. This is the pass that catches the copy-paste artifact from an earlier draft, the snippet that references a variable you renamed three sections ago, the step you accidentally deleted while editing for length.&lt;/p&gt;

&lt;p&gt;Three build passes sound like a lot until you consider that each one is catching a completely different class of mistake: the first confirms the thing is &lt;em&gt;possible&lt;/em&gt;, the second confirms your &lt;em&gt;narration&lt;/em&gt; of it is accurate, the third confirms your &lt;em&gt;edited, final&lt;/em&gt; narration is still accurate after you've been moving paragraphs around for an hour.&lt;/p&gt;

&lt;p&gt;The next few tips are "cosmetic." They don't improve the correctness of your article, but they make it better by a huge margin.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Show Full File Paths in Code Snippets
&lt;/h2&gt;

&lt;p&gt;A code block that starts with &lt;code&gt;export default function Page() {&lt;/code&gt; and nothing else creates more questions than it answers. Where does this go? Is it a new file? Does it replace something? Which folder, at what level, relative to what?&lt;/p&gt;

&lt;p&gt;Show the actual path, every time, even when it feels repetitive: &lt;code&gt;frontend/src/app/api/quote/route.ts&lt;/code&gt;, not &lt;code&gt;route.ts&lt;/code&gt;. Especially in a monorepo, or anything with more than one plausible &lt;code&gt;src/&lt;/code&gt;, guessing the path is exactly the kind of low-value puzzle that makes a reader feel dumb for a reason that has nothing to do with their skill and everything to do with your laziness. Full paths cost you only a few extra words per snippet and save the reader a headache and a moment of quiet resentment toward you specifically.&lt;/p&gt;

&lt;p&gt;Here you might argue that if the audience is an expert, surely they can figure out where to put the code if I say "the quote route" instead of &lt;code&gt;frontend/src/app/api/quote/route.ts&lt;/code&gt;, and that's a valid argument. I prefer to reduce confusion as much as possible, so I almost always mention the full path, regardless of the audience. Better safe than sorry.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Clearly Indicate Where to Put the Code
&lt;/h2&gt;

&lt;p&gt;Adjacent to the path problem, but distinct: a snippet with a correct path can still land in the wrong &lt;em&gt;place&lt;/em&gt; inside that file. "Add this" is not an instruction. "Add this snippet at the top of &lt;code&gt;frontend/src/lib/strapi.ts&lt;/code&gt;, right after the existing imports" is. "Replace the &lt;code&gt;return&lt;/code&gt; statement at the bottom of the &lt;code&gt;handler&lt;/code&gt; function with the following" is. Say whether it's a new file, an insertion, or a replacement, and say exactly where the insertion point is relative to something the reader can already see in their own editor.&lt;/p&gt;

&lt;p&gt;This matters more, not less, as snippets get shorter. A ten-line code block dropped in the middle of a paragraph with no context is the single most common way I've seen a technically correct tutorial become a frustrating one — not because the code was wrong, but because the reader had to guess what to do with it and guessed wrong.&lt;/p&gt;

&lt;p&gt;Don't refer to code by line number, like "Paste this code after line 15." Even if a reader has been following your article and has been copy pasting the exact codes from your article, they may end up having different line numbers. The most common reason is IDE auto-formatting that automatically formats the code as they paste. The reader themselves may decide to format the code in a different way. Always indicate lines via some sort of marker, like "Paste this after the closing  tag."&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Provide Screenshots, Outputs and a Repo
&lt;/h2&gt;

&lt;p&gt;Three different deliverables, all necessary in my opinion.&lt;/p&gt;

&lt;p&gt;The screenshot has two jobs. One, it is proof, aimed at the reader following along: "here's what it should look like when you get this right," so they can check their own result against something concrete instead of just trusting that the code compiled. It's also aimed at people skimming to decide whether the article is worth their time at all — a screenshot of a real, working thing does more convincing in half a second than three paragraphs of description.&lt;/p&gt;

&lt;p&gt;It's second purpose is to give the reader a visual cue. A screenshot of the web page with the button circled in red is easier and faster to understand than "Click the small + button in the top right corner of the page."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note: Two things you must never forget with screenshots: Point out the interesting things (for example, circle the button you want the reader to click, or put an arrow next to it) and include an alt text and caption.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The code outputs have a similar responsibility as the screenshots. They provide a visual indicator. If the user gets the same output as you, they will know they are on the right path. To switch things up a bit (or to save yourself for pasting a huge output), you can also indicate what to look for. For example, "If the &lt;code&gt;status&lt;/code&gt; command shows &lt;code&gt;Running&lt;/code&gt;, you're good to go.&lt;/p&gt;

&lt;p&gt;The repo is the escape hatch, aimed at the reader who's stuck. Tutorials are read out of order, skimmed, half-followed, interrupted by lunch. A linked repo at the final commit state means "I did something slightly wrong three steps back" doesn't have to become "I give up" — it becomes "let me diff my folder against theirs." Also, a repo is a better proof of "It works" than a screenshot. Someone can simply check out the repo and test whether the app works or not before deciding to read your article. I link a repo on effectively everything now, and it's the single piece of feedback I get thanked for most often, more than any clever explanation in the prose itself.&lt;/p&gt;

&lt;p&gt;I link the repo twice. The first link is before starting the tutorial section, saying something like "All the code I am using in this article can be found in this repo." This gives the reader the escape hatch mentioned earlier before they actually start investing into the tutorial. The second link is after the tutorial is over, with something like "You can check out the full app in this repo." This works as a "tie everything together" moment. If they didn't check out the repo earlier, they can do so now, and compare their app with mine.&lt;/p&gt;

&lt;p&gt;A couple of principles I follow when creating a repo for my tutorials:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The repo should be the exact same as what my article produces. I basically use the repo I got from following step 6. This makes sure someone who is reading the article gets the exact same app as someone who simply checked out the repo. I don't do any "final touches" or improvements over what the article has. If I must, I make sure it's not an extensive functionality change. For example, I used basic CSS in the article, but that looks ugly, so I added some CSS styles in the final app. This is ok. But using SQLite in the article because it's easy, but changing it to PostGreSQL in the repo is too big of a change. Not cool.&lt;/li&gt;
&lt;li&gt;Sometimes I may provide a starter version of an app. In case the initial setup is too long, I provide a small prebuilt app that I want the reader to use as a starting point for the article. In that case, I provide the starter version and the final version in different branches of the same repo. Starter version in &lt;code&gt;starter&lt;/code&gt; and final version in &lt;code&gt;main&lt;/code&gt;, or starter version in &lt;code&gt;main&lt;/code&gt; and final version in &lt;code&gt;final&lt;/code&gt; -- choose whatever suits you.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  10. Provide a List of Prerequisites
&lt;/h2&gt;

&lt;p&gt;How many times have you started reading a tutorial and halfway through you realized, "Looks like I need an AWS account for this. No one made that clear!" You don't want to be the person who leaves their readers in the dark about what they need for the tutorial. That's why I add a prerequisites section before the tutorial actually starts. There, I list:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The tools that are needed, e. g. Docker, Node.js&lt;/li&gt;
&lt;li&gt;The versions of the tools, where to install them, and which versions I used.&lt;/li&gt;
&lt;li&gt;If any third-party tools or services are needed, and where to sign up for them.&lt;/li&gt;
&lt;li&gt;If the tools behave sufficiently differently based on OS.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's one example prerequisite section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;To follow along with this tutorial, you will need:
&lt;span class="p"&gt;
*&lt;/span&gt; An Auth0 account. You can sign up for free &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;here&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;link-to-auth0-registration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;.
&lt;span class="p"&gt;*&lt;/span&gt; The latest version of Node.js. This article uses version 22.0.1. You can download Node from &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;here&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;link-to-node-website&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Be the Tutorial Nobody Has to Debug
&lt;/h2&gt;

&lt;p&gt;None of the ten points above are secret techniques picked up from some technical writing seminar. Every one of them is really the same instinct wearing a different hat: don't trust what you think you know, trust what actually happened five minutes ago on your own machine. Every "vibes" tutorial I've ever gotten burned by skipped some of these -- usually not out of laziness, but because it &lt;em&gt;felt&lt;/em&gt; done already, and the deadline was in three hours.&lt;/p&gt;

&lt;p&gt;That's the trap, and it's the same trap every time. A tutorial reads as finished to its author long before it's finished for the reader, because the author already knows what everything means. The reader doesn't. Every point above is just a way of closing that gap -- one more rebuild, one more real file path, one more screenshot, one more line telling them what account to go create before they get 60% of the way in and hit a wall you knew was coming.&lt;/p&gt;

&lt;p&gt;I've written these for clients and the ones that got read, bookmarked, and pasted into someone's internal wiki were never the ones with the cleverest explanation of &lt;em&gt;why&lt;/em&gt; something works. They were the ones that just worked, on the first try, for a total stranger, with no help from me sitting next to them. That's the whole job. Everything else is decoration on top of that one requirement.&lt;/p&gt;

&lt;p&gt;So if you want to be the best tutorial writer in the room, here's the short version: be the one whose tutorial nobody has to debug.&lt;/p&gt;

</description>
      <category>devrel</category>
      <category>programming</category>
      <category>writing</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building a Privacy-Focused Social Media: What It Taught Me</title>
      <dc:creator>Aniket Bhattacharyea</dc:creator>
      <pubDate>Fri, 07 Aug 2026 08:43:10 +0000</pubDate>
      <link>https://dev.to/heraldofsolace/building-a-privacy-focused-social-media-what-it-taught-me-3ogi</link>
      <guid>https://dev.to/heraldofsolace/building-a-privacy-focused-social-media-what-it-taught-me-3ogi</guid>
      <description>&lt;p&gt;I've been working on a side project called Vidha for the past few months. It's a social platform with a somewhat unusual premise: instead of choosing between "public" and "private" like every other platform, you control exactly who sees what about you, and it's not just "show my profile picture to my friends and hide it from the public." You can show different versions of every piece of information you enter in Vidha. You can show a formal picture in suits to your office colleagues, the picture you took on your vacation to your close friends, and the picture with your fiancée to your family — all at the same time. Each attribute is scoped to an audience you define.&lt;/p&gt;

&lt;p&gt;I want to be upfront: this is purely a personal educational project. There's no company behind it, no funding, no roadmap I'm pitching to anyone. I built it to learn. Specifically, I wanted to understand what actually goes into building a social platform — not the surface-level stuff, but the data model decisions that haunt you at 2 am three weeks in.&lt;/p&gt;

&lt;p&gt;This article is about the things that surprised me, the decisions I made and would change, and the problems that turned out to be much harder than they looked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;p&gt;Before diving in, here's what I built this with:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Framework&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Rails 8.1 with Ruby 3.4.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Database&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Frontend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hotwire (Turbo + Stimulus), Tailwind CSS v4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auth&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Devise (with &lt;code&gt;:confirmable&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Activity Feeds&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GetStream.io (&lt;code&gt;stream-ruby&lt;/code&gt; v6)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Background Jobs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Solid Queue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rich Text&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;ActionText&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Deployment&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Railway&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dev Environment&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Nix devenv&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I deliberately chose a "boring" stack. I'm a big fan of Rails because of how robust it is, and how easy it is to get started. The hard problem here is the domain — the visibility system, the feed logic, the moderation — not the infrastructure. Rails is good at getting out of the way, and that's what I needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Idea: Audiences, Not Privacy Modes
&lt;/h2&gt;

&lt;p&gt;The central concept in Vidha is &lt;strong&gt;groups&lt;/strong&gt;. Every user has named groups — "Public", "Followers", "Close Friends", whatever they want. Every profile attribute (display name, bio, location, profile picture) is associated with a group rather than directly with the profile. When someone views your profile, the app figures out which group they belong to and shows only the attributes you've configured for that group.&lt;/p&gt;

&lt;p&gt;The app only requires your email and a username to register. You're free to define your own attributes, such as &lt;strong&gt;Location&lt;/strong&gt;, &lt;strong&gt;Job&lt;/strong&gt;, etc., and assign a type, such as &lt;strong&gt;String&lt;/strong&gt;, &lt;strong&gt;Image&lt;/strong&gt;, &lt;strong&gt;Number&lt;/strong&gt;, or &lt;strong&gt;URL&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffckjlryy6wyrc9qixeo3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffckjlryy6wyrc9qixeo3.png" alt="Creating a new attribute definition" width="800" height="175"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once you define a new attribute, you can assign different values to it for different groups.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F70ptd2qw7u9e16m9xaow.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F70ptd2qw7u9e16m9xaow.png" alt="Adding attribute values" width="800" height="267"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The visibility resolution logic looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;visible_attributes_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;group_for_viewer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt;

  &lt;span class="n"&gt;attribute_definitions&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;joins&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:attribute_values&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;attribute_values: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;group_id: &lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pluck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"attribute_definitions.name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"attribute_values.value"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_h&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the group resolution itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;group_for_viewer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;public_group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;is_public: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;public_group&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nil?&lt;/span&gt;

  &lt;span class="n"&gt;membership&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;GroupMembership&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;member_id: &lt;/span&gt;&lt;span class="n"&gt;viewer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;owner_id: &lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;membership&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;membership&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;public_group&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three cases: an unauthenticated visitor gets the public group, an authenticated user with a membership gets their group, and an authenticated user with no membership also gets the public group. The fallback to public means your public attributes are always visible to logged-in users who don't follow you. That's a deliberate design decision — if you've put something in your public group, you want strangers to see it.&lt;/p&gt;

&lt;p&gt;There are a few "special" attributes (for now, only &lt;strong&gt;Display name&lt;/strong&gt; and &lt;strong&gt;Profile picture&lt;/strong&gt;) that the app uses automatically. Again, you're free to skip them, in which case you get a placeholder value. If you keep your display name undefined, it'll show up as "Anonymous". If you keep your profile picture set to undefined, it'll show a placeholder image.&lt;/p&gt;

&lt;p&gt;Conceptually, this is clean. In practice, every new feature affecting profiles had to thread through the same logic, which meant many opportunities to get it subtly wrong. There are many edge cases. For example, you don't have to define the value of an attribute for every group. In that case, it falls back to the public group. &lt;/p&gt;

&lt;p&gt;You can then attach groups to posts, and only members of those groups will see the post.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1napwx6ko4qabwnl3o3b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1napwx6ko4qabwnl3o3b.png" alt="Post interface" width="799" height="447"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Posts and Comments Are Tricky
&lt;/h2&gt;

&lt;p&gt;Oh, did I mention that posts support full rich-text, including image attachments? And so do comments. You can also mention a user by typing @, and you can reply to a comment. All of that brings new problems.&lt;/p&gt;

&lt;p&gt;ActionText looks simple until you actually deploy it. The first surprise was CORS. ActionText supports direct uploads — the browser uploads images straight to your storage backend (in my case, an S3-compatible bucket on Railway), bypassing Rails entirely. That means your S3 bucket needs CORS headers that allow PUT requests from your app's origin; otherwise, the upload fails in production while working perfectly in development with local disk storage. I spent an embarrassingly long time trying to set CORS policies to the bucket from Railway's web dashboard, but it turns out that you need to do that via the S3 CLI. The dashboard lacks the feature.&lt;/p&gt;

&lt;p&gt;The second surprise was unattached blobs. When a user picks an image in the editor, Active Storage creates and uploads the blob immediately — before the form is submitted. If they close the tab, navigate away, or just change their mind, that blob sits in your storage bucket forever, attached to nothing. The Rails guide shows how you can &lt;a href="https://guides.rubyonrails.org/active_storage_overview.html#purging-unattached-uploads" rel="noopener noreferrer"&gt;purge this&lt;/a&gt; using a custom Rake task, but it's up to you to perform this regularly. However, there might be an interesting edge case where your cleanup job runs while someone is in the middle of writing a post or comment and it deletes the files they had uploaded. I don't have a solution for this yet.&lt;/p&gt;

&lt;p&gt;The third was mentions. ActionText gives you rich HTML, but @username detection has to be layered on top manually. I scan the post body's plain text for the pattern, batch-look up real users, and write GetStream notifications:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;notify_mentions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;comment_id: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;text&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;try&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:to_plain_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;
  &lt;span class="n"&gt;usernames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;MENTION_PATTERN&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniq&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;usernames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;empty?&lt;/span&gt;

  &lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;username: &lt;/span&gt;&lt;span class="n"&gt;usernames&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;not&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;mentioned&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;object&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;comment_id&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;"comment:&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;comment_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"post:&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;add_to_feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mentioned&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="ss"&gt;actor:      &lt;/span&gt;&lt;span class="s2"&gt;"user:&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;verb:       &lt;/span&gt;&lt;span class="s2"&gt;"mention"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;foreign_id: &lt;/span&gt;&lt;span class="s2"&gt;"mention:&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:user:&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;mentioned&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;post_slug:  &lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;post_title: &lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rendering mentions as links in the stored HTML is a separate problem. ActionText's stored HTML is a sanitized fragment, so I wrote a helper that uses Nokogiri to walk the text nodes, split on the @username pattern, and replace matches with anchor tags — being careful to collect all text nodes before modifying the tree, because mutating the DOM mid-traversal skips nodes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;text_nodes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;traverse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;text_nodes&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text?&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;MENTION_SPLIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;text_nodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;MENTION_SPLIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;html&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;MENTION_MATCH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt;
      &lt;span class="sx"&gt;%(&amp;lt;a href="/users/&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;CGI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;escapeHTML&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sx"&gt;"&amp;gt;@&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;CGI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;escapeHTML&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sx"&gt;&amp;lt;/a&amp;gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="no"&gt;CGI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;escapeHTML&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;
  &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Nokogiri&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;HTML&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DocumentFragment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Data Model Rabbit Hole
&lt;/h2&gt;

&lt;p&gt;I spent a genuinely embarrassing amount of time on the data model before writing a single controller. Every entity that seemed simple on day one kept revealing a layer of hidden complexity. The biggest example: &lt;code&gt;GroupMembership&lt;/code&gt;. My first instinct was to allow a follower to be in multiple groups simultaneously. You're my colleague and my friend — why can't you be in both the "Colleagues" and "Friends" groups?&lt;/p&gt;

&lt;p&gt;The answer is: because then I don't know which group's attributes to show you. The visibility resolution breaks the moment a viewer can belong to more than one group. So I added a hard constraint: &lt;strong&gt;a follower belongs to exactly one group per owner&lt;/strong&gt;. That one constraint simplified everything downstream. But it also meant that moving someone to a new group is a replace operation, not an add — and I had to make sure the feed subscriptions stayed in sync whenever that happened.&lt;/p&gt;

&lt;p&gt;Another fun one: every new user automatically gets a "Public" group and a "Followers" group, created in &lt;code&gt;after_create&lt;/code&gt; callbacks. Those two groups are protected — you can't delete them, and you can't have more than one of each. But I also had to make sure that a deleted user doesn't cascade-delete their group memberships in a way that violates foreign key constraints before the groups themselves are removed. The fix was a &lt;code&gt;before_destroy&lt;/code&gt; callback that strips the protected flags first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;after_create&lt;/span&gt;  &lt;span class="ss"&gt;:create_public_group&lt;/span&gt;
&lt;span class="n"&gt;after_create&lt;/span&gt;  &lt;span class="ss"&gt;:create_followers_group&lt;/span&gt;
&lt;span class="n"&gt;before_destroy&lt;/span&gt; &lt;span class="ss"&gt;:strip_protected_group_flags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;prepend: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_public_group&lt;/span&gt;
  &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="s2"&gt;"Public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;is_public: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;strip_protected_group_flags&lt;/span&gt;
  &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;is_public: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;is_followers: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;prepend: true&lt;/code&gt; matters — without it, dependent destroys on associations run before this callback, and you'd get constraint violations.&lt;/p&gt;




&lt;h2&gt;
  
  
  Activity Feeds Are Harder Than They Look
&lt;/h2&gt;

&lt;p&gt;I didn't want to roll my own fan-out-on-write feed system, so I integrated &lt;a href="https://getstream.io" rel="noopener noreferrer"&gt;GetStream&lt;/a&gt;. It handles the mechanics: when a post is published to a group feed, it fans out to every timeline that follows that feed. My job is just to keep the follow/unfollow graph correct. That turned out to be enough work. Here's what happens when someone follows another user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add_to_followers_group_and_follow_feeds&lt;/span&gt;
  &lt;span class="n"&gt;followers_group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;followed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;is_followers: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;public_group&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;followed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;is_public: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;target_group&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;followers_group&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;public_group&lt;/span&gt;

  &lt;span class="no"&gt;GroupMembership&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_or_create_by!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;group: &lt;/span&gt;&lt;span class="n"&gt;target_group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;member: &lt;/span&gt;&lt;span class="n"&gt;follower&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;timeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;StreamClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"timeline"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;follower_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;timeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"group"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;timeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"group"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;public_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;public_group&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;target_group&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;public_group&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The follower's timeline subscribes to &lt;strong&gt;two&lt;/strong&gt; feeds: the &lt;code&gt;followers&lt;/code&gt; group feed and the &lt;code&gt;public&lt;/code&gt; group feed. That's because a post can be shared to either group and the follower should see both.&lt;/p&gt;

&lt;p&gt;And on unfollow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;remove_from_group_and_unfollow_feeds&lt;/span&gt;
  &lt;span class="n"&gt;membership&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;GroupMembership&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;member_id: &lt;/span&gt;&lt;span class="n"&gt;follower_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;owner_id: &lt;/span&gt;&lt;span class="n"&gt;followed_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;timeline&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;StreamClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;feed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"timeline"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;follower_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;public_group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;followed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;is_public: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;timeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unfollow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"group"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;membership&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;timeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unfollow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"group"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;public_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;public_group&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;membership&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group_id&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;public_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;

  &lt;span class="n"&gt;membership&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's an edge case buried in there: if someone is in the public group (their only group), you'd call &lt;code&gt;unfollow&lt;/code&gt; on the same feed twice. Hence the &lt;code&gt;if&lt;/code&gt; check.&lt;/p&gt;

&lt;p&gt;The biggest mistake I made here: I stubbed the GetStream client in tests with a double. Every test passed. Then I actually ran the app and discovered that &lt;code&gt;stream-ruby&lt;/code&gt; v6 changed its constructor from keyword args to positional args. My stub accepted anything, so it never caught it. Lesson learned — if you're going to stub an external client, at least verify the calling convention against the actual gem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Private Accounts and the Follow Request
&lt;/h2&gt;

&lt;p&gt;When a user sets their account to private, new followers need approval. This is a common feature on social platforms and sounds simple. It is not. The core model is a &lt;code&gt;FollowRequest&lt;/code&gt; that remains in a pending state until it is approved or rejected. But the follow flow now has four distinct paths, and each one has to keep groups, feeds, and notifications in sync:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;follow(public account)    → create Follow immediately
follow(private account)   → create FollowRequest, notify owner
approve(follow_request)   → destroy FollowRequest, create Follow, assign group
cancel(pending request)   → destroy FollowRequest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I made the mistake of putting most of the side-effect logic in model callbacks — &lt;code&gt;after_create&lt;/code&gt; and &lt;code&gt;after_destroy&lt;/code&gt; on &lt;code&gt;Follow&lt;/code&gt;. It kept the controllers thin, which felt clean at the time. But callbacks are hard to test in isolation, and when something went wrong, the stack trace was a mess of implicit triggers.&lt;/p&gt;

&lt;p&gt;Also, there's a subtle interaction with Devise. I added an &lt;code&gt;is_invited&lt;/code&gt; flag to gate access during the closed beta:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;active_for_authentication?&lt;/span&gt;
  &lt;span class="k"&gt;super&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;is_invited?&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;inactive_message&lt;/span&gt;
  &lt;span class="n"&gt;is_invited?&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="ss"&gt;:inactive&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I changed the column default from &lt;code&gt;true&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt;, every single test broke — 204 failures at once. The &lt;code&gt;sign_in&lt;/code&gt; helper in RSpec goes through Warden's full session validation, which calls &lt;code&gt;active_for_authentication?&lt;/code&gt;. All my test users had &lt;code&gt;is_invited: false&lt;/code&gt; by default, and Warden was silently blocking them. The fix was adding &lt;code&gt;is_invited: true&lt;/code&gt; to every &lt;code&gt;User.new(...)&lt;/code&gt; call in every request spec. Tedious, but at least the cause was obvious once I figured it out. Lesson learned - think about closed betas as early as possible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Invisible Mode: Like u/[deleted] But Better
&lt;/h2&gt;

&lt;p&gt;Vidha also has an invisible mode for the privacy fanatics. Invisible mode is a toggle users can enable from their account settings. When it's on, the user effectively doesn't exist from anyone else's perspective: their profile returns a 404, they're excluded from search results and member typeahead suggestions, and attempting to follow them returns a 404 too. It's more private than a private account — closer to "ghost mode." The implementation is blunt but honest: every entry point that could expose the user has an explicit guard.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# UsersController&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;show&lt;/span&gt;
  &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RecordNotFound&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@user&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="vi"&gt;@user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invisible_mode?&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;follow&lt;/span&gt;
  &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="ss"&gt;:not_found&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invisible_mode?&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# Search queries exclude invisible users at the DB level&lt;/span&gt;
&lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"username ILIKE ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;term&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;invisible_mode: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raise ActiveRecord::RecordNotFound approach is intentional — it renders the same 404 page as any other missing resource, leaking no information about whether the account exists at all. However, you can still comment (shows up as "Anonymous"), and your posts can be viewed by someone with a direct link. This is to make sure you can still use the social media as intended. I suppose this might come in handy for activists or protesters who don't want to reveal their identity, yet want to post on the platform.&lt;/p&gt;

&lt;p&gt;One thing I'm undecided on: right now, people who already follow you can still see your timeline posts even in invisible mode (the feed subscription persists). Whether that's a bug or a feature is genuinely unclear to me. Also, this feature is pretty terrifying: Anything that can leak info about an invisible user is a big no-no, and I have to make sure every new feature respects invisible users.&lt;/p&gt;

&lt;p&gt;Currently the best approach in my mind is this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;When an invisible user joins a community (or a visible user switches to invisible), it creates a pseudo-username, like "anon-abcde". This pseudo-username is scoped to the user and community. So the same user will get different pseudo-usernames in different communities.&lt;/li&gt;
&lt;li&gt;This pseudo-username will be visible to the mods, and they can see the list of posts and comments by this user and ban/suspend them.&lt;/li&gt;
&lt;li&gt;Since the same user will get different pseudo-usernames in different communities, it means if someone is a moderator in two different communities, they'll not be able to link the same user in those communities.&lt;/li&gt;
&lt;li&gt;This pseudo-username is persistent. So, if the user changes back to visible or leaves and rejoins the community, that won't generate a new one. This prevents someone from circumventing the ban by leaving and rejoining.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Communities: Like Subreddits, But Simpler
&lt;/h2&gt;

&lt;p&gt;Later in the build, I added communities — shared spaces with their own post feeds, member lists, and moderation. A community post publishes to a &lt;code&gt;"community"&lt;/code&gt; feed on GetStream instead of the author's personal group feeds. Membership works the same way: joining subscribes your timeline feed to the community's feed, while leaving unsubscribes.&lt;/p&gt;

&lt;p&gt;The trickiest part was access control. A post in a private community should be visible only to members. A personal post should only be visible to people in the right group. Both &lt;code&gt;PostsController&lt;/code&gt; and &lt;code&gt;CommentsController&lt;/code&gt; need this logic. Here's the concern I extracted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;PostVisibility&lt;/span&gt;
  &lt;span class="kp"&gt;extend&lt;/span&gt; &lt;span class="no"&gt;ActiveSupport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Concern&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;can_view_post?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;community_id?&lt;/span&gt;
      &lt;span class="n"&gt;community&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;community&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;community&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_private?&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;community&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;creator_id&lt;/span&gt;
      &lt;span class="no"&gt;CommunityMembership&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;community_id: &lt;/span&gt;&lt;span class="n"&gt;community&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;user_id: &lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user_id&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kp"&gt;true&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:is_public?&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;
      &lt;span class="no"&gt;GroupMembership&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;member_id: &lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;group_id: &lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I initially had variations of this logic scattered across both controllers before I noticed I was copying code and extracted it into a separate file. I should have written it in one place from the beginning. Access control sprawl is a real thing — every new context (show, edit, destroy, comment) tempts you to just paste the check inline. I'm thinking about using an authorization gem like CanCanCan or Pundit, but I'm not sure how big a rewrite it'd be or whether it'd be worth it. Lesson learned: Think about authorization and access control from the beginning, and try to reuse the logic as much as possible.&lt;/p&gt;

&lt;p&gt;One more thing that I'm still trying to figure out: How should invisible accounts interact with community moderation? Letting invisible accounts run rampant in a community is a recipe for disaster, so moderators should have some way of telling "invisible dude A" from "invisible dude B." One approach is to let a moderator view a "community profile" of an invisible user: just the posts they have made in that particular community, plus the ban/suspend/remove buttons. But that still doesn't answer how to identify that particular user in the user list. This is something I'm thinking about very hard.&lt;/p&gt;




&lt;h2&gt;
  
  
  Frontend: Hotwire Over React
&lt;/h2&gt;

&lt;p&gt;I chose Hotwire (Turbo + Stimulus) and deliberately avoided React. The whole point was to stay inside the Rails idiom and keep the stack simple. For most things, this worked well. Likes are a good example — toggling a like or dislike replaces just the button component in place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;respond_with_reaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_kind&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;respond_to&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;turbo_stream&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;turbo_stream: &lt;/span&gt;&lt;span class="n"&gt;turbo_stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;dom_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@likeable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:likes&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="ss"&gt;partial: &lt;/span&gt;&lt;span class="s2"&gt;"likes/like_button"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;locals: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="ss"&gt;likeable: &lt;/span&gt;&lt;span class="vi"&gt;@likeable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="ss"&gt;liked:    &lt;/span&gt;&lt;span class="n"&gt;current_kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"like"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="ss"&gt;disliked: &lt;/span&gt;&lt;span class="n"&gt;current_kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"dislike"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="nb"&gt;format&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;redirect_back&lt;/span&gt; &lt;span class="ss"&gt;fallback_location: &lt;/span&gt;&lt;span class="n"&gt;root_path&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No JavaScript state management, no API endpoint, no serializer. The server renders the updated partial and sends it down the wire. For the 90% case, this is genuinely fast to build.&lt;/p&gt;

&lt;p&gt;The 10% case is where Hotwire gets awkward: complex client-side state that doesn't map cleanly to server-rendered partials. The rich text editor with group targeting, for instance, involves coordinating ActionText, a custom group selector, and a preview — all in one form. Stimulus controllers are composable, but they don't share state easily. A few times I genuinely wished I had a React app.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Tell Myself at the Start
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Model the domain on paper first.&lt;/strong&gt; The group-based visibility system is the core of the entire app. I arrived at the right data model through iteration, but a couple of hours on a whiteboard before writing any code would have saved me a week of refactoring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't stub external services in tests and call it done.&lt;/strong&gt; Stubs lie. They accept anything and catch nothing. Either test against the real service (with a sandbox account), or write a thin adapter layer that the stub actually validates against (or read the documentation well).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Access control needs a home from day one.&lt;/strong&gt; Don't spread visibility checks across controllers. Put them somewhere dedicated — a concern, a policy object, a query object — and route everything through it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Callbacks hide complexity, they don't eliminate it.&lt;/strong&gt; Model callbacks feel clean until you're debugging a 12-step trigger chain at midnight. For anything that touches external services or multiple models, a service object with explicit methods is worth the extra file.&lt;/p&gt;




&lt;p&gt;Building Vidha has been the most effective learning exercise I've done in a while. Every feature that looks simple from the outside — following someone, liking a post, showing a profile — has a graph of constraints underneath it. You don't see that graph until you try to build it yourself.&lt;/p&gt;

&lt;p&gt;If you're thinking about building something similar, I'd say: start with the hardest domain problem and work outward from there. The framework will take care of itself.&lt;/p&gt;

&lt;p&gt;And if you want to check out Vidha, go ahead and &lt;a href="https://vidha.online" rel="noopener noreferrer"&gt;sign up&lt;/a&gt;. Then drop a comment with your username so that I can activate your account. Expect bugs, lots of them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Vidha is a personal side project built for my own education. It is not a commercial product.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>socialmedia</category>
      <category>rails</category>
      <category>buildinpublic</category>
      <category>ai</category>
    </item>
    <item>
      <title>The Best AI Code Review Tools of 2026</title>
      <dc:creator>Aniket Bhattacharyea</dc:creator>
      <pubDate>Fri, 20 Feb 2026 13:25:32 +0000</pubDate>
      <link>https://dev.to/heraldofsolace/the-best-ai-code-review-tools-of-2026-2mb3</link>
      <guid>https://dev.to/heraldofsolace/the-best-ai-code-review-tools-of-2026-2mb3</guid>
      <description>&lt;p&gt;Early AI code review tools had a math problem. For every real bug they caught, they flagged nine false positives. Teams got buried in comments about variable naming conventions and whitespace. They started ignoring the bot entirely. Productivity tanked.&lt;/p&gt;

&lt;p&gt;The tools that made it past 2025 didn't just throw better models at the problem. They rebuilt how code review works from scratch.&lt;/p&gt;

&lt;p&gt;This guide covers the AI code review tools worth using in 2026, what they're actually good at, and which specific problems they solve for your team.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Problem: Context Windows vs. Large Diffs
&lt;/h2&gt;

&lt;p&gt;AI reviewers break down when you feed them too much code at once. A 1,000-line diff overwhelms the context window. The model loses coherence, misses connections between changes, and falls back on pattern matching for style issues.&lt;/p&gt;

&lt;p&gt;The same reviewer that produces noise on large diffs produces useful feedback on small ones. The tool didn't get smarter. You gave it a problem it could actually solve.&lt;/p&gt;

&lt;p&gt;This is why the effective tools in 2026 either enforce small changes (Graphite), sacrifice depth for speed (GitHub Copilot), or index your entire codebase upfront (Greptile).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F13qiqz7gotd26i1961wa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F13qiqz7gotd26i1961wa.png" alt="AI code review tools" width="800" height="977"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool Comparison Matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;th&gt;Platform Support&lt;/th&gt;
&lt;th&gt;Analysis Depth&lt;/th&gt;
&lt;th&gt;False Positive Rate&lt;/th&gt;
&lt;th&gt;Price/User/Mo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Graphite Agent&lt;/td&gt;
&lt;td&gt;Teams adopting stacked PRs&lt;/td&gt;
&lt;td&gt;GitHub only&lt;/td&gt;
&lt;td&gt;Deep (full codebase)&lt;/td&gt;
&lt;td&gt;~3% unhelpful&lt;/td&gt;
&lt;td&gt;\$40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Copilot&lt;/td&gt;
&lt;td&gt;Existing Copilot users&lt;/td&gt;
&lt;td&gt;GitHub only&lt;/td&gt;
&lt;td&gt;Surface (diff-based)&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;\$10-39 (bundled)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CodeRabbit&lt;/td&gt;
&lt;td&gt;Multi-platform teams&lt;/td&gt;
&lt;td&gt;GitHub, GitLab, Bitbucket, Azure&lt;/td&gt;
&lt;td&gt;Surface (diff-based)&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;\$24-30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Greptile&lt;/td&gt;
&lt;td&gt;Maximum bug detection&lt;/td&gt;
&lt;td&gt;GitHub, GitLab&lt;/td&gt;
&lt;td&gt;Deep (full codebase)&lt;/td&gt;
&lt;td&gt;Highest&lt;/td&gt;
&lt;td&gt;\$30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BugBot&lt;/td&gt;
&lt;td&gt;Cursor-native teams&lt;/td&gt;
&lt;td&gt;GitHub only&lt;/td&gt;
&lt;td&gt;Medium (8-pass diff)&lt;/td&gt;
&lt;td&gt;Low-Medium&lt;/td&gt;
&lt;td&gt;\$40 + Cursor&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Graphite Agent
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://graphite.com/features/agent" rel="noopener noreferrer"&gt;Graphite Agent&lt;/a&gt; combines full-codebase understanding with &lt;a href="https://graphite.dev/guides/stacked-diffs" rel="noopener noreferrer"&gt;stacked PRs&lt;/a&gt;. Instead of one massive pull request, you break changes into small, dependent PRs that merge in sequence.&lt;/p&gt;

&lt;p&gt;Here's how stacked PRs work:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc4trtetydfbmpxtriqig.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc4trtetydfbmpxtriqig.png" alt="Stacked PR" width="800" height="124"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Shopify reported 33% more PRs merged per developer after adoption, with 75% of PRs now going through Graphite. &lt;a href="https://graphite.com/customer/asana" rel="noopener noreferrer"&gt;Asana&lt;/a&gt; saw engineers save 7 hours weekly, ship 21% more code, and cut median PR size by 11%.&lt;/p&gt;

&lt;p&gt;Agent maintains an &lt;a href="https://graphite.com/guides/effectiveness-and-limitations-of-ai-code-review" rel="noopener noreferrer"&gt;unhelpful comment rate under 3%&lt;/a&gt;. When it flags an issue, developers change the code 55% of the time. Human reviewers hit 49%.&lt;/p&gt;

&lt;p&gt;The tool provides one-click fixes, resolves CI failures inline, and includes a &lt;a href="https://graphite.dev/features/merge-queue" rel="noopener noreferrer"&gt;merge queue&lt;/a&gt; that coordinates landing changes in order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The constraint:&lt;/strong&gt; GitHub-only, and your entire team needs to adopt stacked workflows. For teams that commit to this change, median PR merge time drops from 24 hours to 90 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; &lt;a href="https://graphite.dev/pricing" rel="noopener noreferrer"&gt;Team plan&lt;/a&gt; at \$40/user/month with unlimited reviews. Free tier for individuals. Enterprise pricing on request.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitHub Copilot Code Review
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.github.com/en/copilot/using-github-copilot/code-review/using-copilot-code-review" rel="noopener noreferrer"&gt;GitHub Copilot Code Review&lt;/a&gt; hit general availability in April 2025 and reached 1 million users in a month. You assign Copilot as a reviewer like any teammate. It leaves inline comments with suggested fixes.&lt;/p&gt;

&lt;p&gt;The October 2025 update added context gathering. Copilot now reads source files, explores directory structure, and integrates &lt;a href="https://codeql.github.com/" rel="noopener noreferrer"&gt;CodeQL&lt;/a&gt; and ESLint for security scanning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it's good at:&lt;/strong&gt; Zero friction if you already pay for Copilot. Catches typos, null checks, and simple logic errors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it misses:&lt;/strong&gt; Architectural problems and cross-file dependencies. It's diff-based, so it only sees what changed in the PR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; Bundled with Copilot subscriptions (\$10-39/month depending on tier). Code review features not available on free tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  CodeRabbit
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://coderabbit.ai/" rel="noopener noreferrer"&gt;CodeRabbit&lt;/a&gt; is the most widely installed AI code review app on GitHub and GitLab. Over 2 million repositories connected, 13 million+ PRs processed. It runs automatically on new PRs, leaving line-by-line comments with severity rankings and one-click fixes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The advantage:&lt;/strong&gt; Platform breadth. Supports GitHub, GitLab, Bitbucket, and Azure DevOps. Integrates 40+ linters and SAST scanners. Offers self-hosted deployment for enterprises with 500+ seats.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The limitation:&lt;/strong&gt; Diff-based analysis. It sees what changed in the PR, not how changes interact with your codebase. Independent benchmarks gave it a 1/5 completeness score for catching systemic issues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; Pro plan at \$24-30/user/month. Free tier with basic PR summaries. Enterprise plans with self-hosting available.&lt;/p&gt;

&lt;h2&gt;
  
  
  Greptile
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.greptile.com/" rel="noopener noreferrer"&gt;Greptile&lt;/a&gt; indexes your entire repository and builds a code graph. It uses multi-hop investigation to trace dependencies, check git history, and follow leads across files.&lt;/p&gt;

&lt;p&gt;Version 3 (late 2025) uses the Anthropic Claude Agent SDK for autonomous investigation. The tool shows you evidence from your codebase for every flagged issue.&lt;/p&gt;

&lt;p&gt;At \$30/developer/month with a \$180M valuation after its Benchmark-led Series A, Greptile offers the deepest context-aware analysis available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tradeoff:&lt;/strong&gt; Highest catch rate, but also highest false positive rate in independent evaluations. You get more real bugs and more noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; \$30/developer/month for unlimited reviews. Discounts for annual commitments. Open-source projects may qualify for free usage. Self-hosted and enterprise pricing on request.&lt;/p&gt;

&lt;h2&gt;
  
  
  BugBot
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://cursor.com/bugbot" rel="noopener noreferrer"&gt;BugBot&lt;/a&gt; from Cursor launched in July 2025 and reviews 2 million+ PRs monthly. It runs 8 parallel review passes with randomized diff order on every PR, catching bugs that single-pass reviewers miss.&lt;/p&gt;

&lt;p&gt;The "Fix in Cursor" button jumps you from review comment to editor with the fix pre-loaded. Discord's engineering team reported BugBot finding real bugs on human-approved PRs. Over 70% of flagged issues get resolved before merge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The constraint:&lt;/strong&gt; Tightly coupled to Cursor. You need a Cursor subscription, and it works best when your team already uses Cursor as their primary editor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing:&lt;/strong&gt; \$40/user/month plus Cursor subscription. 14-day free trial. GitHub-only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Smaller PRs Get Better Results
&lt;/h2&gt;

&lt;p&gt;Research shows 30-40% cycle time improvements for PRs under 500 lines, with diminishing returns above that threshold. Teams using stacked PRs ship 20% more code with 8% smaller median PR size, saving roughly 10 hours per week waiting to merge.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhzh98xu4zf6n023ldayh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhzh98xu4zf6n023ldayh.png" alt="Bugbot" width="800" height="398"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same AI reviewer produces signal on a 150-line diff and noise on a 1,000-line one. The tool didn't change. The workflow gave it a solvable problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision Framework
&lt;/h2&gt;

&lt;p&gt;Pick based on what you're willing to change:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No workflow changes:&lt;/strong&gt; Start with GitHub Copilot if you already pay for it. Zero setup, catches obvious bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-platform support:&lt;/strong&gt; CodeRabbit is the only option that works across GitHub, GitLab, Bitbucket, and Azure DevOps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Maximum bug detection:&lt;/strong&gt; Greptile's full-codebase indexing finds issues other tools miss. Accept higher noise as the tradeoff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cursor workflow:&lt;/strong&gt; If your team lives in Cursor, BugBot extends your existing setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workflow transformation:&lt;/strong&gt; Graphite treats &lt;a href="https://graphite.dev/guides/code-review-best-practices" rel="noopener noreferrer"&gt;code review&lt;/a&gt; as a systems problem. The numbers from Shopify (33% more PRs per developer) and Asana (7 hours saved weekly) came from adopting stacked workflows, not just adding AI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integration and Security
&lt;/h2&gt;

&lt;p&gt;AI code review tools plug directly into GitHub, GitLab, and Bitbucket as automated reviewers. They integrate with CI/CD pipelines, offer IDE plugins for real-time feedback, and support webhook triggers for automatic reviews on code push.&lt;/p&gt;

&lt;p&gt;Security varies by provider. Look for encryption in transit and at rest, SOC 2 compliance, and clear data retention policies. Some tools offer self-hosted options for maximum control. Graphite has a &lt;a href="https://graphite.com/docs/privacy-and-security" rel="noopener noreferrer"&gt;privacy-first approach&lt;/a&gt; that guarantees code stays private and isn't used for model training.&lt;/p&gt;

&lt;p&gt;Cost ranges from \$10-50 per user monthly for standard plans. GitHub Copilot Code Review bundles with existing subscriptions (\$10-39/month). Enterprise plans with custom rules and dedicated support cost more. Self-hosted options may use infrastructure-based pricing instead of per-user costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Matters
&lt;/h2&gt;

&lt;p&gt;The AI code review tools that survived 2025 didn't just add smarter models. They rethought workflows. Graphite built a platform around stacked changes. GitHub Copilot traded depth for zero friction. CodeRabbit went for breadth across platforms. Greptile went all-in on context. BugBot integrated tightly with an editor.&lt;/p&gt;

&lt;p&gt;The right tool depends on what you're willing to change. If you want AI review with no disruption, GitHub Copilot works. If you need multi-platform support, CodeRabbit is your only option. If catching deep bugs matters more than noise, Greptile's full-codebase indexing finds things others miss. If your team lives in Cursor, BugBot fits naturally.&lt;/p&gt;

&lt;p&gt;If you're willing to change how your team works, not just add a bot, Graphite treats code review as a workflow problem. Stacked PRs, AI review, and merge queue work together in ways that separate tools can't replicate. The productivity gains came from the workflow change, not just the AI.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>code</category>
      <category>github</category>
    </item>
    <item>
      <title>Stacking up Graphite in the World of Code Review Tools</title>
      <dc:creator>Aniket Bhattacharyea</dc:creator>
      <pubDate>Fri, 23 Jan 2026 13:07:13 +0000</pubDate>
      <link>https://dev.to/heraldofsolace/stacking-up-graphite-in-the-world-of-code-review-tools-5fbn</link>
      <guid>https://dev.to/heraldofsolace/stacking-up-graphite-in-the-world-of-code-review-tools-5fbn</guid>
      <description>&lt;p&gt;&lt;strong&gt;A developer's guide to navigating the crowded market of AI code review assistants in 2026.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Review Bot" Explosion
&lt;/h2&gt;

&lt;p&gt;2025 is the year every engineering team tried to automate code review. If you opened a pull request this year, chances are an AI left a comment on it. Some of those comments caught real bugs. Many did not.&lt;/p&gt;

&lt;p&gt;The problem isn't that AI code review doesn't work. It's that the market is flooded with tools that look similar on the surface but solve fundamentally different problems. One tool might catch off-by-one errors in your IDE before you even push. Another scans your entire repository history to understand cross-file dependencies. A third lives in your GitHub comments, leaving feedback that ranges from brilliant to pedantic.&lt;/p&gt;

&lt;p&gt;For the average developer or tech lead, it's hard to tell the difference between a wrapper around an LLM and an actual platform. This article cuts through that noise.&lt;/p&gt;

&lt;p&gt;I'll compare five tools—Graphite, GitHub's native review, CodeRabbit, Greptile, and BugBot—using three criteria:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Depth&lt;/strong&gt;: Does it understand your whole codebase, or just the diff?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workflow&lt;/strong&gt;: Does it change how you review, or just what you read?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Noise&lt;/strong&gt;: Does it help you ship faster, or spam your PR timeline?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let's start by categorizing these tools by what they actually do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Category 1: The "Workflow Platform"
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkwg0xz22xzi72a6d6yf1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkwg0xz22xzi72a6d6yf1.png" alt="Graphite interface" width="800" height="499"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool: Graphite Agent
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://graphite.com" rel="noopener noreferrer"&gt;Graphite&lt;/a&gt; is an AI-augmented code review and pull request workflow platform designed to help teams ship higher-quality code faster by rethinking how reviews and merges work. It integrates AI feedback directly into pull requests, supports stacked pull request workflows, and provides automated merge orchestration.&lt;/p&gt;

&lt;p&gt;When you open a pull request with Graphite, the Graphite Agent provides AI-driven feedback directly in the PR page. It focuses on delivering high-signal insights rather than large volumes of superficial comments. The platform is built to help teams catch substantive bugs and issues early in the review cycle. &lt;/p&gt;

&lt;p&gt;A key workflow innovation is &lt;strong&gt;stacked pull requests&lt;/strong&gt;. With the Graphite CLI and tooling, developers can create a series of dependent PRs—each building on the last—so you can continue working on PR #3 while PR #1 and #2 are still under review. When an earlier PR merges, subsequent stacked changes are automatically rebased, helping reduce merge conflicts and keeping development unblocked.&lt;/p&gt;

&lt;p&gt;Graphite also includes a &lt;strong&gt;stack-aware merge queue&lt;/strong&gt;. Instead of merging PRs sequentially and running CI for each one in isolation, the merge queue can batch and test multiple PRs in parallel once they’re ready, speeding up the merge process and reducing wait times. &lt;/p&gt;

&lt;p&gt;In addition to AI review and stacking workflows, Graphite offers a modern PR review interface with unified inboxes, GitHub integrations, a CLI, and editor extensions (e.g., VS Code). These features aim to streamline the development and review experience and reduce context switching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for&lt;/strong&gt;: Teams that find traditional GitHub review workflows slow or fragmented, and that would benefit from structured, smaller changesets, automated rebasing, and integrated AI feedback throughout the pull request lifecycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing&lt;/strong&gt;:  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free (Hobby)&lt;/strong&gt;: Essentials, including CLI for stacked PRs, VS Code extension, and limited access to Graphite Agent and AI reviews.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Starter&lt;/strong&gt;: $20/user/month (billed annually), includes support for all GitHub organization repos and team insights.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team&lt;/strong&gt;: $40/user/month (billed annually), adds unlimited Graphite Agent access, unlimited AI reviews, review customizations, automations, and merge queue.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise&lt;/strong&gt;: Custom pricing with advanced controls, security features, and support. &lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Category 2: The "Native Giant"
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkqougjqe12r58pvt3kgd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkqougjqe12r58pvt3kgd.png" alt="Enabling Copilot in VS Code" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool: GitHub
&lt;/h2&gt;

&lt;p&gt;GitHub’s native code review workflow is the baseline most teams start from. It is reliable, familiar, and requires no additional tooling beyond what teams are already using for source control and pull requests.&lt;/p&gt;

&lt;p&gt;GitHub has added AI-assisted review features through Copilot (including Copilot for Business and Copilot Enterprise). These capabilities can summarize pull requests, explain changes, and suggest improvements directly in comments. In practice, the quality of these suggestions varies: some feedback is genuinely helpful, while other comments are generic or low value. Importantly, Copilot acts as an assistive layer rather than a fully autonomous review agent.&lt;/p&gt;

&lt;p&gt;A core limitation is architectural. GitHub’s pull request model is fundamentally linear: one branch, one PR, one review lifecycle. There is no native support for stacked or dependent pull requests, which makes it harder to work on large changes incrementally without introducing workflow friction.&lt;/p&gt;

&lt;p&gt;For static analysis and security scanning, GitHub offers &lt;a href="https://codeql.github.com/" rel="noopener noreferrer"&gt;CodeQL&lt;/a&gt;. Public repositories get CodeQL scanning by default. For private repositories, full CodeQL integration—security alerts, dashboards, and UI support—requires GitHub Advanced Security, which introduces additional cost and setup. CodeQL analysis is deep and reliable, but it prioritizes correctness over speed and is not optimized for fast, conversational PR feedback like AI-first reviewers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for&lt;/strong&gt;: Teams that value stability and minimal workflow disruption, and that prefer to stay within the existing GitHub ecosystem. If adopting new tools or changing review workflows is a hard sell, GitHub’s native features are often “good enough” for baseline review automation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notable limitation&lt;/strong&gt;: Merge queue availability is restricted to higher-tier GitHub plans and is not universally accessible. Many teams still merge PRs sequentially and run CI per-PR rather than benefiting from batch testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Category 3: The "Comment Bots"
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5lwjlluuhem07ef0krfk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5lwjlluuhem07ef0krfk.png" alt="CodeRabbit" width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool: CodeRabbit
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.coderabbit.ai" rel="noopener noreferrer"&gt;CodeRabbit&lt;/a&gt; is a feature-rich AI code reviewer that operates entirely within pull request comments. It combines large language models with a broad set of linters and security scanners to generate comprehensive feedback on code changes. In addition to identifying issues, it can generate PR summaries, create sequence diagrams, suggest fixes, and propose test cases.&lt;/p&gt;

&lt;p&gt;The breadth of analysis is both its main strength and its primary trade-off. CodeRabbit surfaces a wide range of findings, but this often includes stylistic feedback and minor suggestions alongside substantive issues. As a result, teams frequently invest time in tuning configuration and rules to reduce noise and focus the output on what matters most for their codebase.&lt;/p&gt;

&lt;p&gt;CodeRabbit integrates with major source control platforms, including GitHub, GitLab, Bitbucket, and Azure DevOps. It also provides a VS Code extension that enables pre-PR review, allowing developers to catch issues before opening a pull request. This broad platform support makes it suitable for organizations operating across multiple repositories or hosting providers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for&lt;/strong&gt;: Open source maintainers or teams that want extensive automated feedback directly in their existing PR workflow, without introducing a new review interface. Its multi-platform support is particularly valuable for organizations that are not GitHub-only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing&lt;/strong&gt;: Offers a free tier with basic features such as PR summarization. Paid plans are approximately $24–$30 per user per month (Pro), depending on billing, and unlock advanced analysis and customization options.&lt;/p&gt;

&lt;h2&gt;
  
  
  Category 4: The "Deep Context" Engine
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8edxn7v7rkesmpuzj9i6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8edxn7v7rkesmpuzj9i6.png" alt="Greptile" width="800" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool: Greptile
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.greptile.com" rel="noopener noreferrer"&gt;Greptile&lt;/a&gt; takes a fundamentally different approach. Instead of analyzing just the PR diff, it builds a full graph of your codebase—understanding how functions connect, where code is used, and how similar patterns have evolved over time.&lt;/p&gt;

&lt;p&gt;This deep context means Greptile can catch issues that span multiple files or reference historical changes. It's like having a reviewer who has memorized your entire repository. The trade-off is complexity: Greptile requires more setup and resources than a simple diff-based tool.&lt;/p&gt;

&lt;p&gt;Greptile emphasizes enterprise features: self-hosted deployment, custom AI models, SOC 2 compliance, and support for both GitHub and GitLab. You can run it on-premises if your security team requires it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for&lt;/strong&gt;: Large codebases where understanding cross-module dependencies is critical. If you're working on a system with millions of lines of code and complex architectural patterns, Greptile's full-repo analysis catches issues that simpler tools miss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing&lt;/strong&gt;: ~$30/user/month (cloud), enterprise negotiation for on-prem. 100% off for open-source and 50% off for startups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Category 5: The "Pre-Merge" Hunter
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx68ycq975jfhx08xsuik.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx68ycq975jfhx08xsuik.png" alt="BugBot" width="800" height="529"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool: BugBot (by Cursor)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://cursor.com/docs/bugbot" rel="noopener noreferrer"&gt;Bugbot&lt;/a&gt; is a narrowly focused AI code review tool designed to identify logic bugs and security issues with a low false-positive rate. Rather than acting as a full code review or workflow platform, it concentrates on surfacing concrete, actionable problems in code changes.&lt;/p&gt;

&lt;p&gt;Bugbot primarily operates on GitHub pull requests. When a PR is opened, it automatically reviews the diff and leaves comments highlighting potential bugs, edge cases, or security concerns. For users of the Cursor IDE, flagged issues can be opened directly in the editor, where suggested fixes can be applied or modified before committing updates.&lt;/p&gt;

&lt;p&gt;The tool is optimized for depth over breadth. It intentionally avoids stylistic feedback and general code quality suggestions, focusing instead on issues that are likely to cause incorrect behavior or security risk. Bugbot also supports custom review rules, allowing teams to encode project-specific constraints and conventions.&lt;/p&gt;

&lt;p&gt;The primary limitation is scope. Bugbot does not aim to replace a full code review platform: it does not generate PR summaries, manage approvals, or integrate with project management workflows. Its role is confined to automated detection of substantive bugs within pull requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for&lt;/strong&gt;: Individual contributors or teams who want automated, high-signal bug detection during PR review—especially those already using Cursor. It can be particularly useful in environments where logic or security defects carry a high operational cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing&lt;/strong&gt;: Bugbot offers a limited free tier for Cursor Teams and Individual plan users. Paid plans are approximately $40 per user per month, depending on usage limits and team features, and are available as add-ons to Cursor plans.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison Matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Graphite&lt;/th&gt;
&lt;th&gt;GitHub&lt;/th&gt;
&lt;th&gt;CodeRabbit&lt;/th&gt;
&lt;th&gt;Greptile&lt;/th&gt;
&lt;th&gt;BugBot&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Installation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GitHub App + CLI&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;td&gt;GitHub App&lt;/td&gt;
&lt;td&gt;GitHub App + API&lt;/td&gt;
&lt;td&gt;GitHub App&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Primary Focus&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Workflow + AI&lt;/td&gt;
&lt;td&gt;Manual Review&lt;/td&gt;
&lt;td&gt;PR Comments&lt;/td&gt;
&lt;td&gt;Deep Context&lt;/td&gt;
&lt;td&gt;PR Comments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Analysis Scope&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PR diff + context&lt;/td&gt;
&lt;td&gt;PR diff&lt;/td&gt;
&lt;td&gt;PR diff + linters&lt;/td&gt;
&lt;td&gt;Full repo graph&lt;/td&gt;
&lt;td&gt;Current PR + existing PR comments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Noise Level&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Very Low (~5%)&lt;/td&gt;
&lt;td&gt;Varies&lt;/td&gt;
&lt;td&gt;Medium-High&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Very Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Stacked PRs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Native&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Merge Queue&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Enterprise only&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pricing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$20-40/user&lt;/td&gt;
&lt;td&gt;Included + Copilot&lt;/td&gt;
&lt;td&gt;$12-30/user&lt;/td&gt;
&lt;td&gt;~$30/user&lt;/td&gt;
&lt;td&gt;Limited usage included with Cursor&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Which Tool Fits Your Stack?
&lt;/h2&gt;

&lt;p&gt;There's no universal winner. The right choice depends on where your team is blocked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your bottleneck is the review process itself&lt;/strong&gt;—PRs sit too long, developers get blocked, context-switching kills momentum—Graphite's workflow improvements matter more than any AI feature. The stacked PRs and merge queue can cut review time from days to hours, even before the AI comments help.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're fully invested in GitHub and Microsoft tools&lt;/strong&gt;, the native features are adequate. You won't get workflow improvements, but you also won't need to onboard a new platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you need cross-platform support&lt;/strong&gt; or want comprehensive coverage including linters and security scanners, CodeRabbit delivers. Be prepared to tune its configuration to reduce noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're working on a massive legacy codebase&lt;/strong&gt; where understanding historical context is critical, Greptile's full-repo analysis is worth the complexity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're already using Cursor IDE&lt;/strong&gt; and want to catch bugs before they are merged, BugBot is the most focused solution.&lt;/p&gt;

&lt;p&gt;The real question isn't "which AI reviewer is best?" It's "what's actually slowing down my team's shipping velocity?" Answer that first, then pick the tool that addresses it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What are you running in production?&lt;/strong&gt; Are you trusting AI to auto-approve yet, or strictly using it for summaries? I'm curious what everyone else is seeing. Drop a comment below.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>codereview</category>
      <category>coding</category>
      <category>api</category>
    </item>
  </channel>
</rss>
