Testing

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Thymeleaf 3.1.5.RELEASE, Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring Security 7.1.1), JUnit 5, Mockito, HtmlUnit 4.21.0 (the version managed by the Spring Boot BOM), htmx-spring-boot 5.2.0 and Java 21, checked on 2026-10-08. Every test shown was compiled and run on that stack in a scratch Bookshelf application (Maven wrapper, JDK 26 with --release 21); the output blocks are the real output of those runs. The page applies to Thymeleaf with Spring MVC. Thymeleaf standalone is covered by the engine-level tests in the dialect section and by the thymeleaf-testing section, and WebFlux applications use WebTestClient instead of MockMvc (see Reactive Rendering with WebFlux; the WebFlux test API is not covered here).

A Thymeleaf template is code that runs on the server and produces HTML for a browser, so a good test suite checks three different things: that the controller picks the right view and fills the model, that the template turns that model into the right HTML, and that the browser does the right thing with the HTML (links, forms, JavaScript, htmx). Each layer has a cheaper and a more expensive tool, and this page walks through them from the cheapest to the most expensive using the Bookshelf introduced on the Getting Started page.

The test pyramid for a Thymeleaf application

flowchart TD e2e["End-to-end: a real browser against a running server - Playwright and Testcontainers - few tests, slowest"] htmlunit["Page-level: HtmlUnit through MockMvc - forms, links, validation messages - no JavaScript"] slice["Slice tests: WebMvcTest with MockMvc or MockMvcTester - view name, model, rendered HTML, security, htmx headers"] unit["Engine-level and unit tests: TemplateEngine with a string or classpath resolver, processors and expression objects - many tests, fastest"] e2e --- htmlunit htmlunit --- slice slice --- unit
Table 1. What each layer proves and what it cannot see
Layer Proves Cannot see Typical cost

Engine-level and unit tests

A dialect, processor or expression object does what it should for a given template and context; no Spring needed

Spring MVC, security, message sources, the real view resolver

Milliseconds

@WebMvcTest slice (MockMvc, MockMvcTester)

Request mapping, binding, validation, view name, model, the HTML text produced by the real Thymeleaf engine, Spring Security rules, htmx request headers

JavaScript, CSS, what a browser makes of the HTML, the database (the repository is a mock)

A second per context

HtmlUnit over MockMvc

That the rendered page can be used: the form has the fields the controller expects, the CSRF token is present, error messages show up after a failed submit

JavaScript-heavy behaviour (htmx swaps, Alpine), layout, real browser quirks

Tens of milliseconds per page

End-to-end with a browser

The whole stack including htmx, JavaScript, the database and the browser

Nothing, but failures are slow to diagnose

Seconds per test

Most tests should sit at the two lower layers. Rendered HTML is plain text, so a slice test can check it without a browser; reserve the browser for behaviour that only a browser has.

Dependencies

Spring Initializr adds the test starters that match the starters you chose. In Spring Boot 4 the testing support is split in one test starter per technology, so a Thymeleaf MVC application with Security needs these test-scoped dependencies. HtmlUnit is not part of a starter and is added by hand; the BOM manages its version.

Test dependencies for the Bookshelf (pom.xml)
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.htmlunit</groupId>
    <artifactId>htmlunit</artifactId>
    <scope>test</scope>
</dependency>

The packages moved too: @WebMvcTest and @AutoConfigureMockMvc now live in org.springframework.boot.webmvc.test.autoconfigure, and @MockitoBean is org.springframework.test.context.bean.override.mockito.MockitoBean (part of Spring Framework, not Spring Boot). The examples below omit the imports.

The scenario

The tests below exercise a small part of the Bookshelf: a search page that returns a full page for a browser and only the result list for htmx, and an admin-only delete button.

ShelfController: one URL, two representations
@Controller
@RequestMapping("/shelf")
public class ShelfController {
    private final BookRepository repo;
    public ShelfController(BookRepository repo) { this.repo = repo; }

    @GetMapping
    public String page(@RequestParam(defaultValue = "") String q, Model model) {
        model.addAttribute("q", q);
        model.addAttribute("books", repo.findByTitleContainingIgnoreCase(q));
        return "shelf/index";
    }

    @HxRequest
    @GetMapping
    public String results(@RequestParam(defaultValue = "") String q, Model model) {
        model.addAttribute("books", repo.findByTitleContainingIgnoreCase(q));
        return "shelf/index :: results";
    }
}
shelf/index.html (excerpt)
<div id="results" th:fragment="results">
  <ul>
    <li th:each="b : ${books}">
      <span th:text="${b.title}">Title</span>
      <span th:if="${b.copies lt 2}" bs:badge="low">Last copy</span>
      <form sec:authorize="hasRole('ADMIN')" th:action="@{/admin/books/{id}/delete(id=${b.id})}" method="post">
        <button type="submit">Delete</button>
      </form>
    </li>
  </ul>
  <p th:if="${#lists.isEmpty(books)}">No books found.</p>
</div>

The bs:badge attribute comes from a custom dialect that is tested in its own section; sec:authorize comes from the Spring Security dialect (Spring Security Integration).

Slice tests with @WebMvcTest

@WebMvcTest starts a Spring context that contains only the web layer: controllers, @ControllerAdvice, converters, filters and the Thymeleaf auto-configuration, but no @Service, @Repository or @Component classes. Because the real ThymeleafViewResolver and the real template engine are in the context, the templates are rendered for real; only the collaborators of the controller are replaced.

MockMvc: status, view, model and content

MockMvc performs a request without a network and returns the result for assertions. For Thymeleaf the useful assertions are view().name() (what the controller returned), model() (what the template will see) and content() (the HTML the engine produced).

A controller test with MockMvc
@WebMvcTest(ShelfController.class)
@Import({SecurityConfig.class, DialectConfig.class})
@WithMockUser
class ShelfControllerTest {

    @Autowired MockMvc mvc;

    @MockitoBean BookRepository books;

    @Test
    void shelfRendersTheViewWithTheModel() throws Exception {
        given(books.findByTitleContainingIgnoreCase(""))
            .willReturn(List.of(new Book("Dune", "Frank Herbert", 3)));

        mvc.perform(get("/shelf"))
            .andExpect(status().isOk())
            .andExpect(view().name("shelf/index"))
            .andExpect(model().attribute("books", hasSize(1)))
            .andExpect(content().string(containsString("<span>Dune</span>")));
    }
}

Three details matter for templates. view().name() checks the name the controller returned, not whether the template exists or renders; the content() assertion is what proves the template rendered. content().string() receives the HTML as one string, so assert on short fragments that matter (a text, an attribute) instead of the whole document, which makes the test brittle against harmless whitespace changes. And @WithMockUser is there because the Bookshelf requires authentication: without a user the request is redirected to the login page (see the security section).

When a content assertion fails and the reason is not obvious, add andDo(print()) (from MockMvcResultHandlers) to the chain: it prints the request, the handler, the model, the redirect and the complete rendered body to the console, which is the quickest way to see what the engine really produced. Remove it once the test is green, because it makes the build log noisy.

Tests build the same Book objects again and again. Keep the construction in one small class, an Object Mother (a factory of ready-made test objects such as Books.dune() or Books.lastCopy()), instead of repeating constructor calls in every test. When the Book constructor changes, only the mother has to change, and a test reads as intent (given(books.findAll()).willReturn(List.of(Books.lastCopy()))) rather than as data.

An Object Mother for the Bookshelf tests
final class Books {
    private Books() {}

    static Book dune() {
        return new Book("Dune", "Frank Herbert", 3);
    }

    static Book lastCopy() {
        return new Book("Solaris", "Stanislaw Lem", 1);
    }
}

MockMvcTester: the AssertJ style

Spring Framework 6.2 (Spring Boot 3.4) added MockMvcTester, a fluent AssertJ wrapper over MockMvc. Spring Boot registers a MockMvcTester bean next to the MockMvc bean in a @WebMvcTest or @AutoConfigureMockMvc context, so it can be injected directly. A failed request no longer needs throws Exception on the test method.

The same test with MockMvcTester
@Autowired MockMvcTester tester;

@Test
void sameTestWithMockMvcTester() {
    given(books.findByTitleContainingIgnoreCase(""))
        .willReturn(List.of(new Book("Dune", "Frank Herbert", 1)));

    assertThat(tester.get().uri("/shelf"))
        .hasStatusOk()
        .hasViewName("shelf/index")
        .model().containsKey("books");
    assertThat(tester.get().uri("/shelf"))
        .bodyText().contains("Dune").contains("badge badge-low");
}

The second assertion reads the rendered body and finds badge badge-low, the CSS classes added by the custom bs:badge attribute: the template, the Thymeleaf engine and the dialect all took part. Prefer MockMvcTester in new code; the older MockMvc API remains fully supported and is still what most existing code and the Spring Security request post-processors use.

Mocking collaborators with @MockitoBean

The slice contains the controller but not the repository it needs, so the test declares a replacement. In Spring Framework 6.2 and later this is @MockitoBean; @MockBean and @SpyBean from org.springframework.boot.test.mock.mockito were deprecated in Spring Boot 3.4 and are no longer in spring-boot-test 4.1.1 (the package is absent from the jar). Older tutorials and books that use @MockBean need that one-line change.

If the mock is missing, the context fails to start with a message that names the missing type:

Output when the repository mock is forgotten
Error creating bean with name 'shelfController': Unsatisfied dependency expressed through constructor parameter 0:
No qualifying bean of type 'com.example.bookshelf.BookRepository' available: expected at least 1 bean which qualifies as autowire candidate.

What the slice does and does not load

The slice loads the beans that belong to the web layer and nothing more. Two kinds of application classes are easy to forget, and in both cases the test still runs, but against the wrong configuration.

The first is your own @Configuration. The Bookshelf’s SecurityConfig (which restricts /admin/** to the ADMIN role) is not picked up by the slice, so the test falls back to Spring Boot’s default security, and a user without the role can delete a book. The test below was run without @Import(SecurityConfig.class):

Output when SecurityConfig is not imported
STATUS>>> 302

The 302 is the controller’s own redirect after a successful delete: the request that the real application rejects with 403 was allowed. Import the configuration (as the examples above do) so that the test checks the rules you wrote.

The second is a custom dialect registered as a bean. Without DialectConfig in the slice, the engine does not know the bs prefix and passes the attribute through unprocessed:

Rendered output when the dialect bean is missing from the slice
<span bs:badge="low">Last copy</span>

With the dialect imported, the same template renders <span class="badge badge-low" data-template="shelf/index">Last copy</span>.

Security in tests

Spring Security’s test support integrates with MockMvc through the spring-boot-starter-security-test dependency. @WebMvcTest already applies Spring Security’s MockMvc configuration, so the annotations below work without extra setup (outside Spring Boot you add springSecurity() to the MockMvc builder yourself). The security rules, the CSRF filter and the Thymeleaf Security dialect all run for real.

@WithMockUser, @WithUserDetails and csrf()

@WithMockUser creates an authentication with the given name and roles without consulting any user store. @WithUserDetails loads a real UserDetails from a UserDetailsService bean, which is useful when the roles come from your own user service. csrf() adds a valid CSRF token to a request; without it, every POST is rejected with 403 because CSRF protection stays on in the test, exactly as in production.

Security rules, CSRF and the Security dialect
@WebMvcTest({ShelfController.class, AdminController.class})
@Import({SecurityConfig.class, DialectConfig.class, ShelfSecurityTest.TestUsers.class})
class ShelfSecurityTest {

    /** Test-only users with a placeholder password; never reuse outside tests. */
    @TestConfiguration
    static class TestUsers {
        @Bean
        UserDetailsService testUsers() {
            return new InMemoryUserDetailsManager(
                User.withUsername("alice").password("{noop}test-only").roles("ADMIN").build());
        }
    }

    @Autowired MockMvc mvc;
    @MockitoBean BookRepository books;

    @Test
    @WithAnonymousUser
    void anonymousUsersAreSentToTheLoginPage() throws Exception {
        mvc.perform(get("/shelf"))
            .andExpect(status().is3xxRedirection())
            .andExpect(redirectedUrl("/login"));
    }

    @Test
    @WithMockUser(roles = "USER")
    void readersCannotDelete() throws Exception {
        mvc.perform(post("/admin/books/1/delete").with(csrf()))
            .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    void postWithoutCsrfTokenIsRejected() throws Exception {
        mvc.perform(post("/admin/books/1/delete"))
            .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    void adminsCanDeleteWithCsrfToken() throws Exception {
        mvc.perform(post("/admin/books/1/delete").with(csrf()))
            .andExpect(status().is3xxRedirection())
            .andExpect(redirectedUrl("/shelf"));
        verify(books).deleteById(1L);
    }

    @Test
    @WithUserDetails(value = "alice", userDetailsServiceBeanName = "testUsers")
    void deleteButtonIsRenderedForAdminsOnly() throws Exception {
        var dune = new Book("Dune", "Frank Herbert", 3);
        ReflectionTestUtils.setField(dune, "id", 7L);
        given(books.findByTitleContainingIgnoreCase(anyString())).willReturn(List.of(dune));

        mvc.perform(get("/shelf"))
            .andExpect(status().isOk())
            .andExpect(content().string(containsString("action=\"/admin/books/7/delete\"")))
            .andExpect(content().string(containsString("name=\"_csrf\"")));
    }

    @Test
    @WithMockUser(roles = "USER")
    void deleteButtonIsHiddenFromReaders() throws Exception {
        given(books.findByTitleContainingIgnoreCase(anyString()))
            .willReturn(List.of(new Book("Dune", "Frank Herbert", 3)));

        var body = mvc.perform(get("/shelf")).andReturn().getResponse().getContentAsString();
        assertThat(body).contains("Dune").doesNotContain("/delete");
    }
}

The first four tests check the filter chain (the redirect to the login page, the role rule, the CSRF rule) and the last two check the template: the sec:authorize expression decides whether the delete form exists, and th:action inserts the hidden _csrf field automatically. The CSRF test documents that protection stays on in tests. A template that posts a form with a plain action attribute instead of th:action renders no token; the MockMvc tests above add csrf() themselves and would not notice, but the HtmlUnit test of the next section submits what the page really contains and fails. The CSRF and method-security parts of the test support are documented on CSRF Testing and Testing Method Security.

The credentials in the example are placeholders that exist only in the test configuration. Never copy a production credential into a test, and do not commit real ones.

HtmlUnit with MockMvc

String assertions on HTML work for short fragments but become awkward when a test must find a form, fill fields and submit it. HtmlUnit is a headless browser model written in Java that can parse the response, expose the DOM and follow form submissions. Spring’s MockMvcWebClientBuilder connects an HtmlUnit WebClient to a MockMvc instance, so requests to localhost are served by the MockMvc context without opening a socket.

The artifact moved from net.sourceforge.htmlunit to org.htmlunit (version 3 and later, packages org.htmlunit.*), which is what Spring Boot 4 manages; code written for the older com.gargoylesoftware.htmlunit packages needs its imports changed.

Filling in and submitting the new-book form
@WebMvcTest(BookController.class)
@Import(SecurityConfig.class)
@WithMockUser
class NewBookHtmlUnitTest {

    @Autowired MockMvc mvc;
    @Autowired WebClient bootWebClient;      // provided by Spring Boot when HtmlUnit is on the classpath
    @MockitoBean BookRepository books;

    @Test
    void submittingTheFormSavesTheBook() throws Exception {
        try (WebClient webClient = MockMvcWebClientBuilder.mockMvcSetup(mvc).build()) {
            HtmlPage page = webClient.getPage("http://localhost/books/new");
            HtmlForm form = page.getForms().get(0);
            form.getInputByName("title").type("Dune");
            form.getInputByName("author").type("Frank Herbert");

            HtmlPage after = form.<HtmlButton>getFirstByXPath(".//button").click();

            assertThat(after.getUrl().getPath()).isEqualTo("/books");
            var saved = ArgumentCaptor.forClass(Book.class);
            verify(books).save(saved.capture());
            assertThat(saved.getValue().getTitle()).isEqualTo("Dune");
        }
    }

    @Test
    void blankTitleShowsTheValidationMessageAndSavesNothing() throws Exception {
        HtmlPage page = bootWebClient.getPage("/books/new");
        HtmlPage after = page.getForms().get(0).<HtmlButton>getFirstByXPath(".//button").click();

        assertThat(after.getBody().asNormalizedText()).contains("must not be blank");
        verify(books, never()).save(any());
    }
}

The first test builds the client by hand with MockMvcWebClientBuilder; the second uses the WebClient bean that Spring Boot’s MockMvc auto-configuration provides when HtmlUnit is on the classpath. Both work with the security setup of the slice: the form page contains the CSRF token (put there by th:action) and HtmlUnit submits it with the other fields. The test never writes a token itself, which is the point: it proves the rendered form is submittable. The second test shows that the validation message from the th:errors span reaches the page after a failed submit (messages and error handling are covered on Validation & Error Messages).

HtmlUnit has a JavaScript engine, but it is not a dependable way to test htmx swaps or modern JavaScript, so use it for classic forms and links and leave htmx-driven behaviour to the next section and to the end-to-end tests. Assertions about layout or CSS cannot be written at this level either.

Testing htmx endpoints

An htmx request is an ordinary HTTP request with extra headers, and the server decides what to render from them. That makes the server side testable with MockMvc by sending the same headers. The library htmx-spring-boot used by the Bookshelf maps @HxRequest to the presence of HX-Request: true (see htmx with Spring Boot); the tests send the header and check which representation comes back.

One URL, two representations, tested with MockMvcTester
@WebMvcTest(ShelfController.class)
@Import({SecurityConfig.class, DialectConfig.class})
@WithMockUser
class ShelfHtmxTest {

    @Autowired MockMvcTester tester;
    @MockitoBean BookRepository books;

    @Test
    void htmxRequestsGetOnlyTheFragment() {
        given(books.findByTitleContainingIgnoreCase("du"))
            .willReturn(List.of(new Book("Dune", "Frank Herbert", 3)));

        var response = tester.get().uri("/shelf").param("q", "du").header("HX-Request", "true");

        assertThat(response)
            .hasStatusOk()
            .hasViewName("shelf/index :: results")
            .bodyText().startsWith("<div id=\"results\">").doesNotContain("<html").contains("Dune");
    }

    @Test
    void plainRequestsGetTheWholePage() {
        given(books.findByTitleContainingIgnoreCase(anyString())).willReturn(List.of());

        var response = tester.get().uri("/shelf");

        assertThat(response)
            .hasStatusOk()
            .hasViewName("shelf/index")
            .bodyText().contains("<html").contains("No books found.");
    }
}

The view name of the fragment response is the fragment selector, and the body starts at the fragment’s root element with no <html> around it. The same style checks response headers (HX-Trigger, HX-Retarget, HX-Redirect) through the response assertions, and other request headers (HX-Target, HX-Boosted) through further .header(...) calls. All of this tests the server’s contract; what the browser does with the response (swapping, focus, history) belongs to the end-to-end layer. The headers are listed on htmx Fundamentals.

Unit testing custom dialects and processors

A dialect is plain Java and can be tested without Spring. There are two useful levels: a test that runs a real TemplateEngine over a tiny template (the closest thing to production), and a test that calls a processor directly with mocks (precise, but tied to the processor’s internals). The dialect under test is the bs dialect used above.

A processor that adds CSS classes and records the template name
public class BadgeAttributeTagProcessor extends AbstractAttributeTagProcessor {

    private static final String ATTR_NAME = "badge";
    private static final int PRECEDENCE = 1000;

    public BadgeAttributeTagProcessor(String dialectPrefix) {
        super(TemplateMode.HTML, dialectPrefix, null, false, ATTR_NAME, true, PRECEDENCE, true);
    }

    @Override
    protected void doProcess(ITemplateContext context, IProcessableElementTag tag, AttributeName attributeName,
                             String attributeValue, IElementTagStructureHandler structureHandler) {
        structureHandler.setAttribute("class", "badge badge-" + attributeValue);
        structureHandler.setAttribute("data-template",
            HtmlEscape.escapeHtml5(context.getTemplateData().getTemplate()));
    }
}

The escaping call is there because a test found a defect: the first version wrote getTemplate() as it was, and with a template resolver that treats the template string as the template body (StringTemplateResolver), the output contained unescaped quotes and angle brackets inside the attribute value. setAttribute stores the value as given, so a processor that copies template-controlled text into an attribute must escape it (see Template Security & XSS).

Engine-level test

Running the dialect in a real engine
class BookshelfBadgeDialectTest {

    private TemplateEngine engine;

    @BeforeEach
    void setUp() {
        var resolver = new ClassLoaderTemplateResolver();
        resolver.setPrefix("badge-templates/");
        resolver.setSuffix(".html");
        resolver.setTemplateMode(TemplateMode.HTML);
        engine = new TemplateEngine();
        engine.setTemplateResolver(resolver);
        engine.addDialect(new BookshelfBadgeDialect());
    }

    @Test
    void rendersTheBadgeClassAndKeepsTheContent() {
        assertThat(engine.process("low", new Context()))
            .isEqualToIgnoringNewLines(
                "<span class=\"badge badge-low\" data-template=\"low\">Last copy</span>");
    }
}

The template src/test/resources/badge-templates/low.html contains <span bs:badge="low">Last copy</span>. This pattern also applies to a standalone engine configured as described on Standalone Engine & Configuration. Using a classpath resolver gives the template a real name; a StringTemplateResolver is shorter but makes the template body its own name.

Calling a processor directly with TemplateData

A processor reads metadata about the running template through ITemplateContext.getTemplateData(), which returns a TemplateData object: the template name, the template selectors, the template resource, the template mode and the cache validity. To test code that depends on it without starting an engine, a test must be able to build a TemplateData itself. Until Thymeleaf 3.1.3 its constructor was package-private, so a test had to be placed in org.thymeleaf.engine or work around it. Since 3.1.4 (released on 2026-04-12) the constructor is public and its Javadoc says it is offered "to be used in testing scenarios"; the TemplateData.java files in the 3.1.3.RELEASE and 3.1.4.RELEASE source jars differ in exactly that modifier. The signature is TemplateData(String template, Set<String> templateSelectors, ITemplateResource templateResource, TemplateMode templateMode, ICacheEntryValidity cacheValidity), and the class still performs no validation of its arguments.

A unit test with a mocked context and a hand-built TemplateData
class BadgeAttributeTagProcessorTest {

    private final ITemplateContext context = mock(ITemplateContext.class);
    private final IElementTagStructureHandler handler = mock(IElementTagStructureHandler.class);

    private void templateNamed(String name) {
        when(context.getTemplateData()).thenReturn(new TemplateData(
            name, Set.of(), new StringTemplateResource("<span/>"), TemplateMode.HTML,
            AlwaysValidCacheEntryValidity.INSTANCE));
    }

    private void process(String value) {
        new BadgeAttributeTagProcessor("bs").doProcess(
            context, mock(IProcessableElementTag.class), mock(AttributeName.class), value, handler);
    }

    @Test
    void setsTheClassAndTheTemplateName() {
        templateNamed("shelf/index");

        process("low");

        verify(handler).setAttribute("class", "badge badge-low");
        verify(handler).setAttribute("data-template", "shelf/index");
    }

    @Test
    void escapesTheTemplateNameBeforeWritingItIntoAnAttribute() {
        templateNamed("\"><script>alert(1)</script>");

        process("ok");

        verify(handler).setAttribute("data-template", "&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;");
    }
}

The test lives in the same package as the processor because doProcess is protected. The second test is the one that guards the escaping fix: it needs a template name that no real engine would produce, which is exactly what a hand-built TemplateData makes possible. Which Thymeleaf version a project has can be checked in the dependency tree: Spring Boot 4.1.1 manages 3.1.5.RELEASE.

The result of running these tests together with the others is shown in the last section.

The thymeleaf-testing library

org.thymeleaf:thymeleaf-testing is a separate library from the Thymeleaf project for testing templates declaratively. A test is a text file with the extension .thtest: lines starting with # are comments and each field starts with a % line (%NAME, %CONTEXT, %INPUT, %OUTPUT and others such as %MESSAGES, %EXCEPTION, %TEMPLATE_MODE, %FRAGMENT and %EXTENDS). A TestExecutor reads a folder or file of tests, processes each %INPUT with a Thymeleaf engine and compares the result with %OUTPUT. Separate context builders exist for Spring MVC and Spring Security (SpringWebProcessingContextBuilder and SpringSecurityWebProcessingContextBuilder).

Status: no release for Thymeleaf 3.1

The library has no 3.1 release. Its last stable version is 3.0.4.RELEASE, and the only 3.1 versions on Maven Central are the milestones 3.1.0.M1 and 3.1.0.M2 (metadata last updated on 2022-05-16). The GitHub repository thymeleaf/thymeleaf-testing is archived, with its last push on 2022-07-07. The 3.0.4 artifact declares a dependency on the javax.servlet API and its Spring integration targets Spring 3, 4 and 5 (the Spring3, Spring4 and Spring5 version-specific initializers are the only ones in the jar); none of this fits Spring Boot 4, Spring Framework 7 or the jakarta namespace. It also depends on Mockito 1.x, which needs --add-opens java.base/java.lang=ALL-UNNAMED on a recent JDK. So for an application on Spring Boot 4 the practical answer is the MockMvc, HtmlUnit and engine tests of this page.

What still runs

For a plain, non-web template test the engine part works. The following experiment was run on its own project (not the Bookshelf) with thymeleaf-testing 3.0.4.RELEASE, Thymeleaf 3.1.5.RELEASE (the 3.0.4 transitive Thymeleaf dependency excluded) and JDK 26. The default context builder creates a javax.servlet web context and fails against Thymeleaf 3.1:

Output with the default web context builder on Thymeleaf 3.1.5
Exception in thread "main" java.lang.NoSuchMethodError: 'void org.thymeleaf.context.WebContext.<init>(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse, javax.servlet.ServletContext, java.util.Locale, java.util.Map)'
	at org.thymeleaf.testing.templateengine.context.web.WebProcessingContextBuilder.doCreateWebContextInstance(WebProcessingContextBuilder.java:178)

Replacing the builder with one that creates a plain Context makes the declarative tests run. The test, the builder and the runner:

books.thtest
# Bookshelf list rendered by plain Thymeleaf (no Spring)
%CONTEXT
books = {'Dune', 'Emma'}
%INPUT
<ul><li th:each="b : ${books}" th:text="${b}">Title</li></ul>
%OUTPUT
<ul><li>Dune</li><li>Emma</li></ul>
A non-web context builder and the runner
public class PlainContextBuilder implements IProcessingContextBuilder {
    @Override
    public IContext build(ITest test) {
        ITestContext tc = test.getContext();
        Locale locale = Locale.ENGLISH;
        Map<String, Object> variables = new HashMap<>();
        for (Map.Entry<String, ITestContextExpression> e : tc.getVariables().entrySet()) {
            variables.put(e.getKey(), e.getValue().evaluate(variables, locale));
        }
        return new Context(locale, variables);
    }
}

public class RunThTests {
    public static void main(String[] args) {
        TestExecutor executor = new TestExecutor();
        executor.setReporter(new ConsoleTestReporter());
        executor.setProcessingContextBuilder(new PlainContextBuilder());
        executor.execute("classpath:thtest");
        System.out.println("Reporter says: " + (executor.isAllOK() ? "ALL OK" : "FAILURES"));
    }
}
Output of the run (paths shortened, run with --add-opens java.base/java.lang=ALL-UNNAMED)
[sequence:start][thtest]
  [test:end][thtest/books.thtest-001][461542][OK] Test executed OK.  Time: 461542ns (0ms).
[sequence:end][thtest][1][1][92163834] Tests OK: 1 of 1. Sequence executed in 92163834ns (92ms)
Reporter says: ALL OK

A second file with a wrong expected output (Persuasion instead of Emma) is reported as a failure with the position of the difference:

Output for a failing .thtest
[test:end][thtest/broken.thtest-001][111156292][KO] Test FAILED: Actual result does not match expected result.
Obtained:
[ul><li>Dune</li><li>Emma</li></ul>]
at line 1 col 22, but expected:
[ul><li>Dune</li><li>Persuasion</li></ul>] Time: 111156292ns (111ms).
...
Tests OK: 1 of 2 (FAILED: 1).
Reporter says: FAILURES

This proves that the engine-level part of the library can drive Thymeleaf 3.1.5 with a hand-written builder, but it is an experiment, not a supported configuration: there is no release built for 3.1, the th: features that need a web or Spring context are not available through the stock builders, and the project is archived. A hand-written JUnit engine test, as in the previous section, gives the same coverage with a maintained toolchain and a failure message from your own assertion library. Treat .thtest files as a legacy format you may meet in older projects (and in Thymeleaf’s own history), not as the recommended way to start.

End-to-end tests with Playwright and Testcontainers

The layers above never run JavaScript the way a browser does and never talk to a real database. For behaviour that depends on htmx swaps, Alpine components, redirects after login or a migration, the Bookshelf needs a browser test. The usual setup in Spring Boot is a @SpringBootTest(webEnvironment = RANDOM_PORT) test that starts the whole application on a free port, a database in a Testcontainers container, and Playwright driving Chromium against that port.

The first half of that setup can be checked without a browser. This test starts the real server and fetches a page over HTTP with the JDK client, which is the base URL that a Playwright page.navigate(...) would use:

A real-server smoke test
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class RealServerSmokeTest {

    @LocalServerPort int port;

    @Test
    void anonymousVisitorsLandOnTheLoginForm() throws Exception {
        var client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
        var request = HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/shelf")).build();

        var response = client.send(request, HttpResponse.BodyHandlers.ofString());

        assertThat(response.uri().getPath()).isEqualTo("/login");
        assertThat(response.body()).contains("name=\"username\"").contains("name=\"_csrf\"");
    }
}

The browser half, including installing the browsers, running headless or headed in CI, and capturing screenshots, videos and traces, is covered on End-to-End Testing with Real Browsers; the Testcontainers side (a PostgreSQL container, @ServiceConnection, and running the browser itself in a container) will be covered with the Playwright, Cypress, Selenium and Selenide material tracked in issue #258. Keep the number of end-to-end tests small, build their data through the application’s own API or a test-only profile (never an unauthenticated reset endpoint in production code), and cover the rest of the logic in the cheaper layers.

Where the Taming Thymeleaf testing chapter is outdated

Chapter 15 of Taming Thymeleaf (the consulted PDF is version 1.1.1, 2021; the current Leanpub edition is updated for Spring Boot 3) is a good outline of the layers: a @WebMvcTest slice, mocked collaborators, MockMvc assertions, an HtmlUnit WebClient, and Cypress for end-to-end tests. A few details have changed.

Table 2. Outdated details and the current approach
In the book Status Current approach

@MockBean for the controller’s collaborators

Deprecated in Spring Boot 3.4 and removed from spring-boot-test 4.1

@MockitoBean from org.springframework.test.context.bean.override.mockito

com.gargoylesoftware.htmlunit packages

Renamed when the project moved

org.htmlunit packages and the org.htmlunit:htmlunit artifact

MockMvc with Hamcrest matchers only

Still supported

MockMvcTester (AssertJ), available since Spring Boot 3.4

spring-boot-starter-test as the single test dependency

In Spring Boot 4 the test support is split by technology

spring-boot-starter-webmvc-test, spring-boot-starter-thymeleaf-test, spring-boot-starter-security-test

Cypress 5, configured with cypress.json and specs in cypress/integration

Cypress 10 replaced these with a cypress.config.js (or .ts) file and the cypress/e2e folder, as the Cypress migration guide describes

A current Cypress (see the migration guide) or Playwright, tracked in issue #258

A test-only endpoint that resets the database, enabled by a profile

Works, but is a risk if the profile leaks into production

Create data through the API or SQL scripts in the test, and keep any test-only endpoint out of production artifacts

The whole suite

All the tests of this page, plus the application’s context-loads test, in one Maven run:

Output of the test run
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, in com.example.bookshelf.ShelfControllerTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, in com.example.bookshelf.BookshelfApplicationTests
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, in com.example.bookshelf.RealServerSmokeTest
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, in com.example.bookshelf.ShelfSecurityTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, in com.example.bookshelf.dialect.BookshelfBadgeDialectTest
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, in com.example.bookshelf.dialect.BadgeAttributeTagProcessorTest
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, in com.example.bookshelf.ShelfHtmxTest
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, in com.example.bookshelf.NewBookHtmlUnitTest
Tests run: 17, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

Pitfalls

Symptom Cause Fix

The context fails with "No qualifying bean of type …​ BookRepository"

The @WebMvcTest slice has the controller but not the repository or service it injects

Declare the collaborator with @MockitoBean, or use @SpringBootTest for a test that should load everything

@MockBean does not compile, or is reported deprecated

It was deprecated in Spring Boot 3.4 and removed from the Boot 4 test module

Use @MockitoBean (Spring Framework) or @MockitoSpyBean

A role-restricted URL is reachable in the test, or a security test passes that should fail

Your SecurityFilterChain configuration class is not part of the slice, so the default Spring Boot security applied

Add @Import(SecurityConfig.class) to the test and add a test that expects 403 for the wrong role

Every POST in the test returns 403

CSRF protection is on and the request carries no token

Add .with(csrf()); in HtmlUnit tests make sure the form is built with th:action so the token is rendered

A custom attribute such as bs:badge is still in the output

The dialect bean is not in the slice, so the engine does not know the prefix

Import the configuration that registers the dialect, or test the dialect with its own TemplateEngine

view().name() passes but the page is broken

The assertion checks the controller’s return value, not that the template exists or renders

Add a content() or bodyText() assertion on a fragment of the rendered HTML

A test on the full page also passes when the htmx fragment is wrong

The request did not send HX-Request: true, so the full-page handler answered

Send the header and assert on the fragment’s view name and on doesNotContain("<html")

The fragment endpoint works in the test but htmx behaves differently in the browser

MockMvc and HtmlUnit do not run htmx

Cover swaps, triggers and history in a small number of end-to-end tests

Whole-page string assertions break on whitespace or attribute order

The test compares large parts of the HTML

Assert on short, meaningful fragments, or parse with HtmlUnit and query the DOM

ClassNotFoundException for com.gargoylesoftware.htmlunit

Imports from the old HtmlUnit packages

Use org.htmlunit and the org.htmlunit:htmlunit dependency

NoSuchMethodError for WebContext with thymeleaf-testing

The library targets javax.servlet and Thymeleaf 3.0; there is no 3.1 release

Do not use it with Spring Boot 4; write engine tests directly, or supply your own context builder as an experiment

A processor test fails with "cannot access the TemplateData constructor"

Before Thymeleaf 3.1.4 the constructor was package-private

Use Thymeleaf 3.1.4 or later (Spring Boot 4.1 manages 3.1.5)

Template-controlled text appears unescaped in an attribute written by a processor

setAttribute writes the value as given

Escape with HtmlEscape before calling it, and keep a test with hostile input