Testing web applications effectively requires more than just verifying HTTP responses. While MockMvc excels at testing Spring Boot controllers and REST endpoints, it falls short when we need to test JavaScript interactions, form submissions with client-side validation, or complex user workflows in our Thymeleaf templates. This is where HtmlUnit shines as a powerful testing tool.
HtmlUnit acts as a “GUI-less browser” that can execute JavaScript, manipulate the DOM, and simulate real user interactions. For Spring Boot applications using Thymeleaf templates, this means we can test not just what the server renders, but how users actually interact with our pages.
In this article, we’ll explore how to leverage HtmlUnit with Spring Boot 3.5 and Java 21 to create comprehensive tests for Thymeleaf views.
Setting up HtmlUnit with Spring Boot
Spring Boot 3.5 includes HtmlUnit support through the standard test starter, making integration seamless. Let’s start with a practical example: a customer management system with forms and tables.
First, ensure your pom.xml
includes the necessary dependencies:
1 2 3 4 5 6 7 8 9 10 |
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.htmlunit</groupId> <artifactId>htmlunit</artifactId> <scope>test</scope> </dependency> |
Note that we can omit the version for the HtmlUnit dependency as it is managed by the Spring Boot parent POM.
Let’s create a Customer entity and controller:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
@Entity public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotBlank(message = "Name is required") @Size(min = 2, max = 50) private String name; @NotBlank(message = "Email is required") @Email(message = "Please provide a valid email") private String email; @Min(value = 18, message = "Age must be at least 18") private Integer age; // Default constructor public Customer() {} // Constructor for testing public Customer(String name, String email, int age) { this.name = name; this.email = email; this.age = age; } // Getters and setters } |
Note: For demonstration purposes, we’re using the entity directly with bean validation annotations. In production-grade applications, you should separate your JPA entities from your request/form objects to maintain clean separation of concerns and avoid tight coupling between your domain model and presentation layer.
Now let’s create the controller:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
@Controller @RequestMapping("/customers") public class CustomerController { private final CustomerService customerService; public CustomerController(CustomerService customerService) { this.customerService = customerService; } @GetMapping public String listCustomers( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "name") String sortBy, Model model) { Page customerPage = customerService.findAll( PageRequest.of(page, size, Sort.by(sortBy))); model.addAttribute("customers", customerPage.getContent()); model.addAttribute("currentPage", page); model.addAttribute("totalPages", customerPage.getTotalPages()); return "customers/list"; } @GetMapping("/new") public String showCreateForm(Model model) { model.addAttribute("customer", new Customer()); return "customers/form"; } @PostMapping public String createCustomer(@Valid @ModelAttribute Customer customer, BindingResult result) { if (result.hasErrors()) { return "customers/form"; } customerService.save(customer); return "redirect:/customers"; } } |
Now, let’s create the Thymeleaf templates. First, the form template (customers/form.html
):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title>Customer Form</title> <script> function validateEmail(email) { const emailRegex = "..."; return emailRegex.test(email); } function showValidationMessage(message) { const messageDiv = document.getElementById('validation-message'); messageDiv.textContent = message; messageDiv.style.display = 'block'; } function validateForm() { const name = document.getElementById('name').value; const email = document.getElementById('email').value; if (name.length < 2) { showValidationMessage('Name must be at least 2 characters'); return false; } if (!validateEmail(email)) { showValidationMessage('Please enter a valid email'); return false; } return true; } </script> </head> <body> <h1>Create Customer</h1> <div id="validation-message" style="display: none; color: red;"></div> <form th:action="@{/customers}" th:object="${customer}" method="post" onsubmit="return validateForm()"> <div> <label for="name">Name:</label> <input type="text" id="name" th:field="*{name}"/> <span th:if="${#fields.hasErrors('name')}" th:errors="*{name}" class="error"></span> </div> <div> <label for="email">Email:</label> <input type="email" id="email" th:field="*{email}"/> <span th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="error"></span> </div> <div> <label for="age">Age:</label> <input type="number" id="age" th:field="*{age}"/> <span th:if="${#fields.hasErrors('age')}" th:errors="*{age}" class="error"></span> </div> <button type="submit">Create Customer</button> </form> </body> </html> |
And the list template (customers/list.html
):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title>Customer List</title> </head> <body> <h1>Customers</h1> <a th:href="@{/customers/new}">Add New Customer</a> <table id="customers-table"> <thead> <tr> <th> <a th:href="@{/customers(page=${currentPage}, sortBy='name')}"> Name </a> </th> <th> <a th:href="@{/customers(page=${currentPage}, sortBy='email')}"> Email </a> </th> <th>Age</th> </tr> </thead> <tbody> <tr th:each="customer : ${customers}"> <td th:text="${customer.name}"></td> <td th:text="${customer.email}"></td> <td th:text="${customer.age}"></td> </tr> </tbody> </table> <div th:if="${totalPages > 1}"> <a th:href="@{/customers(page=0)}">First</a> <a th:if="${currentPage > 0}" th:href="@{/customers(page=${currentPage - 1})}">Previous</a> <a th:if="${currentPage < totalPages - 1}" th:href="@{/customers(page=${currentPage + 1})}">Next</a> <a th:href="@{/customers(page=${totalPages - 1})}">Last</a> </div> </body> </html> |
Testing Thymeleaf views with HtmlUnit
Now we can write comprehensive tests using HtmlUnit.
The key advantage is that HtmlUnit executes JavaScript, allowing us to test client-side validation and dynamic behavior.
Let’s explore three essential testing scenarios with step-by-step explanations.
Understanding the basic test setup
First, let’s understand how to set up a test class for HtmlUnit.
Note that when using HtmlUnit with MockMvc
, you’ll need to configure the WebClient
properly:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
import org.htmlunit.WebClient; @WebMvcTest(CustomerController.class) class CustomerControllerHtmlUnitTest { @Autowired private WebClient webClient; @MockitoBean private CustomerService customerService; // Our tests will go here } |
What each annotation does:
@WebMvcTest(CustomerController.class)
– Creates a test slice that only loads ourCustomerController
and web layer components, and automatically configuresMockMvc
and HtmlUnitWebClient
@Autowired WebClient webClient
– This is our “browser” that can navigate pages and click buttons. Important: This is HtmlUnit’sWebClient
, not the Spring WebFluxWebClient
– they are different classes with the same name@MockitoBean CustomerService customerService
– Creates a mock version of our service so we can control its behavior in tests
Example 1: Testing form submission with valid data
Let’s start with a complete form submission test:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
@Test void testFormSubmissionWithValidData() throws Exception { // Mock the findAll method for the redirect after save Page emptyPage = new PageImpl<>(List.of()); given(customerService.findAll(any(PageRequest.class))).willReturn(emptyPage); // Step 1: Navigate to the form page (like typing URL in browser) HtmlPage formPage = webClient.getPage("/customers/new"); // Step 2: Verify we got the right page assertThat(formPage.getTitleText()).isEqualTo("Customer Form"); // Step 3: Get the form and find the input fields HtmlForm form = formPage.getForms().get(0); HtmlTextInput nameInput = form.getInputByName("name"); HtmlEmailInput emailInput = form.getInputByName("email"); HtmlNumberInput ageInput = form.getInputByName("age"); // Step 4: Fill out the form (like typing in the browser) nameInput.setValueAttribute("John Doe"); ageInput.setValueAttribute("25"); // Step 5: Find and click the submit button HtmlButton submitButton = form.getFirstByXPath("//button[@type='submit']"); HtmlPage resultPage = submitButton.click(); // Step 6: Verify what happened after clicking submit // Check that our service method was called with the right data ArgumentCaptor customerCaptor = ArgumentCaptor.forClass(Customer.class); verify(customerService).save(customerCaptor.capture()); Customer savedCustomer = customerCaptor.getValue(); assertThat(savedCustomer.getName()).isEqualTo("John Doe"); assertThat(savedCustomer.getAge()).isEqualTo(25); } |
Key concepts explained:
HtmlPage
– Represents the entire web page, like what you see in your browserHtmlForm
– Represents a<form>
elementsetValueAttribute("John Doe")
– Types “John Doe” into the name fieldArgumentCaptor
– Captures the arguments passed to our mock service so we can verify them
Example 2: Testing JavaScript validation
Here’s where HtmlUnit really shines – it can execute JavaScript and test client-side behavior:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
@Test void testClientSideValidation() throws Exception { // Mock the findAll method for potential redirects Page emptyPage = new PageImpl<>(List.of()); given(customerService.findAll(any(PageRequest.class))).willReturn(emptyPage); // Step 1: Get the form page HtmlPage formPage = webClient.getPage("/customers/new"); HtmlForm form = formPage.getForms().get(0); // Step 2: Fill form with INVALID data that will trigger JavaScript validation HtmlTextInput nameInput = form.getInputByName("name"); nameInput.setValueAttribute("J"); // Too short - will trigger validation HtmlEmailInput emailInput = form.getInputByName("email"); emailInput.setValueAttribute("invalid-email"); // Invalid format HtmlNumberInput ageInput = form.getInputByName("age"); ageInput.setValueAttribute("25"); // Valid age // Step 3: Try to submit the form HtmlButton submitButton = form.getFirstByXPath("//button[@type='submit']"); // HtmlUnit respects HTML5 validation, so form won't submit with invalid email // Let's fix the email but keep name short to test JavaScript validation HtmlPage samePage = submitButton.click(); // Wait for JavaScript to execute webClient.waitForBackgroundJavaScript(1000); // Step 4: Verify JavaScript validation worked // The validation message should appear on the page HtmlElement validationMessage = samePage.getHtmlElementById("validation-message"); assertThat(validationMessage.isDisplayed()).isTrue(); assertThat(validationMessage.asNormalizedText()).contains("Name must be at least 2 characters"); // Step 5: Verify form wasn't actually submitted (we stay on same page) assertThat(samePage.getUrl()).isEqualTo(formPage.getUrl()); verify(customerService, never()).save(any()); // Step 6: Test with valid data to ensure form can submit successfully nameInput.setValueAttribute("John Doe"); HtmlPage successPage = submitButton.click(); // Now the form should submit successfully verify(customerService).save(any()); } |
What’s happening:
- JavaScript executes when we click submit, preventing form submission for invalid data
isDisplayed()
– Checks if an element is visible on the pagenever().save(any())
– Verifies our service was never called when validation fails- HtmlUnit runs the actual JavaScript code from our template
Important note about HTML5 validation: HtmlUnit respects HTML5 form validation attributes. If you use <input type="email">
, HtmlUnit will prevent form submission with invalid email formats, just like a real browser. In our test, we had to provide a valid email format before testing JavaScript validation for the name field.
Example 3: Testing table content and navigation
Let’s test our customer list page with table data and sorting links:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
@Test void testCustomerTableAndSorting() throws Exception { // Step 1: Set up test data that our mock service will return List customers = List.of( ); Page customerPage = new PageImpl<>(customers, PageRequest.of(0, 10), customers.size()); // Step 2: Tell our mock what to return given(customerService.findAll(any(PageRequest.class))) .willReturn(customerPage); // Step 3: Load the customer list page HtmlPage listPage = webClient.getPage("/customers"); // Step 4: Verify page title and basic structure assertThat(listPage.getTitleText()).isEqualTo("Customer List"); // Step 5: Find and examine the table HtmlTable table = listPage.getHtmlElementById("customers-table"); assertThat(table).isNotNull(); // Step 6: Get the table rows (excluding header) List rows = table.getBodies().get(0).getRows(); assertThat(rows).hasSize(3); // Step 7: Verify table content HtmlTableRow firstRow = rows.get(0); assertThat(firstRow.getCell(0).asNormalizedText()).isEqualTo("Alice"); assertThat(firstRow.getCell(2).asNormalizedText()).isEqualTo("30"); // Step 8: Test sorting by clicking column header link HtmlAnchor nameSort = listPage.getFirstByXPath("//a[@href='/customers?page=0&sortBy=name']"); HtmlPage sortedPage = nameSort.click(); // Step 9: Verify the sorting request was made ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(PageRequest.class); verify(customerService, times(2)).findAll(pageableCaptor.capture()); // Step 10: Check that sorting parameters were passed correctly Pageable sortedPageable = pageableCaptor.getValue(); assertThat(sortedPageable.getSort().getOrderFor("name")).isNotNull(); } |
Important concepts:
PageImpl
– Creates a fake page of results for our mockgetHtmlElementById("customers-table")
– Finds element withid="customers-table"
getCell(0)
– Gets the first cell in a table rowgetAnchorByHref()
– Finds a link (<a>
tag) with specific href attributetimes(2)
– Service called twice: once for initial page load, once after clicking
Comparing HtmlUnit and MockMvc approaches
Understanding when to use each tool is crucial for effective testing.
Let’s compare both approaches with practical examples.
MockMvc: Fast controller testing
MockMvc excels at testing controller logic and HTTP interactions:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
@Test void testCustomerListWithMockMvc() throws Exception { List customers = List.of( ); given(customerService.findAll(any())).willReturn( new PageImpl<>(customers)); mockMvc.perform(get("/customers")) .andExpect(status().isOk()) .andExpect(view().name("customers/list")) .andExpect(model().attribute("customers", hasSize(1))) .andExpect(model().attribute("customers", hasItem(hasProperty("name", is("John"))))); } |
MockMvc provides faster execution and is ideal for:
- Unit testing individual controllers
- Verifying request/response mappings
- Testing security configurations
- Quick feedback during development
HtmlUnit: JavaScript and user interaction testing
HtmlUnit shines when testing JavaScript functionality and complex user workflows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
@Test void testDynamicFormBehavior() throws Exception { HtmlPage page = webClient.getPage("/customers/dynamic-form"); // Test JavaScript-driven form changes HtmlSelect categorySelect = page.getHtmlElementById("category"); categorySelect.setSelectedAttribute("premium", true); // JavaScript should reveal additional fields webClient.waitForBackgroundJavaScript(1000); HtmlElement premiumFields = page.getHtmlElementById("premium-fields"); assertThat(premiumFields.isDisplayed()).isTrue(); // Fill dynamic fields HtmlTextInput discountInput = page.getHtmlElementById("discount"); discountInput.setValueAttribute("15"); // Submit and verify HtmlForm form = page.getForms().get(0); HtmlPage result = form.getOneHtmlElementByAttribute( "button", "type", "submit").click(); verify(customerService).savePremiumCustomer(any()); } |
HtmlUnit offers comprehensive testing capabilities for:
- JavaScript validation and interactions
- Multi-page user workflows
- Form submissions with client-side logic
- Testing the actual rendered HTML structure
Summary of using HtmlUnit with Thymeleaf and Spring Boot
Testing Thymeleaf views effectively requires understanding the strengths of both MockMvc and HtmlUnit. While MockMvc provides fast, lightweight testing for controllers and basic view rendering, HtmlUnit enables comprehensive testing of JavaScript interactions, form validations, and complete user workflows that MockMvc simply cannot handle.
For Spring Boot applications, the integration is seamless through the spring-boot-starter-test dependency.
HtmlUnit’s ability to execute JavaScript makes it invaluable for testing modern web applications where client-side behavior is crucial to the user experience. By combining both tools strategically, we can create a robust testing suite that ensures our Thymeleaf views work correctly from both server-side rendering and client-side interaction perspectives.
The key is to use MockMvc for rapid feedback during development and basic controller testing, while leveraging HtmlUnit for integration tests that verify the complete user experience. This balanced approach provides confidence that our Spring Boot applications deliver the functionality users expect while maintaining reasonable test execution times.
Joyful testing,
Philip