Testing Jakarta Faces Applications: Unit, Integration and Browser Tests
|
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 Jakarta Faces 4.1 (API jakarta.faces-api 4.1.2, part of Jakarta EE 11), Eclipse Mojarra 4.1.17 and Apache MyFaces Core 4.1.4,
Java 21 (the examples were compiled with --release 21 and run on JDK 26), JUnit 5.14.4, Mockito 5.24.0, Hibernate Validator 9.1.4.Final, Arquillian 1.10.2.Final
with Arquillian Drone and Graphene 3.0.1.Final, Selenium 4.50.0, Playwright for Java 1.63.0, HtmlUnit 5.5.0 and PrimeFaces Selenium 16.0.0 (PrimeFaces 16.0.0), on
Apache Tomcat 11.0.26 with Weld 6.0.4.Final. The Arquillian runs use Apache TomEE 10.2.0, which is a Jakarta EE 10 server (Faces 4.0 with MyFaces 4.0.3), and say so where it matters.
Faces 4.0 behaves like 4.1 for everything below except where a section says otherwise. Faces 5.0 is still a milestone release (5.0.0-M9, 2026-09-18) and is not
exercised here.
Every listing on this page is a file of one scratch Maven project, the test side of the Bookshelf application, and each was compiled and run on 2026-10-08. The project has 56 tests in four modules: 20 unit tests, 4 MyFaces Test tests, 29 browser, HTTP and server tests, and 3 Arquillian tests. The unit, browser and HTTP tests ran against the same WAR with Mojarra 4.1.17 and with MyFaces 4.1.4; the tests are written to pass on both, and the one place where that was not possible (HtmlUnit) is described in HtmlUnit: A Browser Written in Java. Statements taken only from documentation are marked as such. No listing contains a credential, a key or any real data.
Testing a Faces application is different from testing a REST API because the thing under test is a state machine that lives on the server and is driven by a browser: a request restores a component tree, applies the submitted values, converts and validates them, updates the model, calls the action and renders. A test can enter that machine at five heights, and the skill is to test each rule at the cheapest height at which it can fail. This page walks up the heights in order of cost: plain objects, mocked Faces objects, a real container without a browser, a browser without a real display, and a real browser. The page on the real-browser end-to-end tests of the web section compares Selenium, Playwright and Cypress in general; this page adds only what is specific to Faces and its component libraries.
What to Test, and at Which Height
The request processing lifecycle (see the lifecycle page) has six phases, and the code you write is called from four of
them: converters and validators from Process Validations, setters from Update Model Values, action methods and listeners from Invoke Application, and getters and
rendered expressions from Render Response. A rule belongs at the lowest height that executes the code, and the table shows what each height executes.
| Height | Lifecycle code that runs | What it proves | What it cannot prove |
|---|---|---|---|
Plain JUnit and Mockito |
None. The test calls the method the lifecycle would call ( |
The rule itself: conversion, validation, branching, messages. |
That the page wires the rule to the right component, in the right phase. |
MyFaces Test mock objects |
One component’s |
Interplay of converter, validators, messages and the model for one input. |
Anything rendered: the mock render kit has no renderers. |
Arquillian in-container |
None by itself; CDI injects into the test class, the bean runs in the container. |
Wiring: CDI scopes, events, interceptors, producers, the container’s own bean validation. |
The page. |
Arquillian client mode, HTTP, HtmlUnit |
All six phases, on the real FacesServlet. |
A page renders, a form posts, the view state round-trips, the Ajax protocol. |
What a real browser’s JavaScript engine does. |
Real browser (Selenium, Playwright, PrimeFaces Selenium, Graphene) |
All six phases, |
Whole features as a user sees them: widgets, dialogs, focus, timing. |
Nothing the others could; it is the slowest and the flakiest. |
The Bookshelf Test Project
One parent POM and four modules keep the heights apart, because they need different dependencies and run at different times. shelf is the Bookshelf WAR with its unit tests;
myfaces-test tests components with mock objects; e2e unpacks the WAR, starts it in an embedded Tomcat and drives it; arquillian deploys test archives to an embedded TomEE.
bookshelf-parent/
pom.xml JUnit BOM, surefire 3.6.0, failsafe 3.6.0, war plugin 3.5.1
shelf/ WAR + unit tests (surefire, *Test)
src/main/java/org/example/bookshelf/{model,service,web}
src/main/webapp/ index.xhtml, review.xhtml, shelf.xhtml, fixture.xhtml, WEB-INF/templates/layout.xhtml
src/test/java/ CatalogueBeanTest, IsbnConverterTest, ...
myfaces-test/ mock Faces objects (surefire)
e2e/ browser, HTTP and server tests (failsafe, *IT)
arquillian/ Arquillian with TomEE embedded (failsafe, *IT)
Two conventions matter more than any tool. Unit tests are named *Test and run in Maven’s test phase with the Surefire plugin; tests that start a server or a browser are named *IT and run in
the integration-test phase with the Failsafe plugin, so that mvn test stays fast and a failing server start does not hide a unit-test result. The second convention is that
tests never depend on a fixed port or on a browser that another build is using: every port in the listings below is a system property with a default, set in the POM.
The unit-test module needs only the APIs at compile time. The Faces implementation and Weld are bundled into the WAR through a profile, so the same sources run on either
implementation (mvn verify for Mojarra, mvn verify -Pmyfaces for MyFaces).
<!-- unit tests -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>9.1.4.Final</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.glassfish.expressly</groupId>
<artifactId>expressly</artifactId>
<version>6.0.0</version>
<scope>runtime</scope>
</dependency>
<profiles>
<!-- mvn -Pmojarra (default) or mvn -Pmyfaces: the Faces implementation bundled into the WAR -->
<profile>
<id>mojarra</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<dependencies>
<dependency>
<groupId>org.glassfish</groupId>
<artifactId>jakarta.faces</artifactId>
<version>${mojarra.version}</version>
<scope>runtime</scope>
</dependency>
</dependencies>
</profile>
<profile>
<id>myfaces</id>
<dependencies>
<dependency>
<groupId>org.apache.myfaces.core</groupId>
<artifactId>myfaces-impl</artifactId>
<version>${myfaces.version}</version>
<scope>runtime</scope>
</dependency>
</dependencies>
</profile>
</profiles>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- Mockito as an agent (no self-attach warning on JDK 21+); a fixed locale for message tests -->
<argLine>-javaagent:${org.mockito:mockito-core:jar} -Duser.language=en -Duser.country=US</argLine>
</configuration>
</plugin>
Source: Apache Maven Surefire Plugin, Apache Maven Failsafe Plugin Mockito: Mockito as a Java agent and JEP 451: Prepare to Disallow the Dynamic Loading of Agents
Three details of that excerpt come from failures, not from taste.
-
The agent. Mockito 5 uses the inline mock maker, which can mock final classes and static methods (both are used below). Without
-javaagent, Mockito attaches itself at run time, and on JDK 26 the run printed "Mockito is currently self-attaching", followed by the JVM warnings "A Java agent has been loaded dynamically" and "Dynamic loading of agents will be disallowed by default in a future release" (JEP 451). Themaven-dependency-pluginpropertiesgoal supplies the jar’s path as${org.mockito:mockito-core:jar}. -
An Expression Language implementation. Hibernate Validator interpolates constraint messages with Jakarta Expression Language, so a plain JVM test of a constraint needs an EL implementation on the class path (here Expressly). Without it the test class fails to start with
HV000183: Unable to initialize 'jakarta.el.ExpressionFactory'(observed). Inside a servlet container or a Jakarta EE server one is already present. -
The locale. Message texts such as "not between the expected values of 1 and 5" depend on the JVM default locale. The
-Duser.language=en -Duser.country=USarguments make every run produce the same text on every machine.
Unit-Testing Backing Beans
A backing bean is a plain class whose collaborators are injected, which is exactly what makes it easy to test, and what the backing beans page meant
by testing the layers without Faces. Two Faces-specific obstacles remain: the bean reaches the FacesContext to add messages, and the container, not the test, calls its @PostConstruct method.
Since Faces 2.3 the specification requires that the FacesContext can be injected into a CDI bean with @Inject (section 5.6.1, changes listed in B.4). A bean written that way receives the context
through a field or a constructor, so a test replaces it with a mock. Mockito can mock FacesContext, an abstract class, directly.
package org.example.bookshelf.web;
import jakarta.annotation.PostConstruct;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import java.util.List;
import org.example.bookshelf.model.Book;
import org.example.bookshelf.service.BookService;
@Named
@ViewScoped
public class CatalogueBean implements Serializable {
private static final long serialVersionUID = 1L;
@Inject
private BookService service;
@Inject
private FacesContext facesContext;
private String query;
private List<Book> books;
@PostConstruct
void load() {
books = service.catalogue();
}
public void search() {
books = service.search(query);
if (books.isEmpty()) {
facesContext.addMessage("catalogue:query", new FacesMessage(FacesMessage.SEVERITY_WARN,
"Nothing found", "No book matches '" + query + "'."));
}
}
public void borrow(long id) {
service.borrow(id, "reader");
facesContext.addMessage(null, new FacesMessage("Borrowed book " + id));
}
public String getQuery() {
return query;
}
public void setQuery(String query) {
this.query = query;
}
public List<Book> getBooks() {
return books;
}
}
package org.example.bookshelf.web;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.List;
import org.example.bookshelf.model.Book;
import org.example.bookshelf.service.BookService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class CatalogueBeanTest {
private static final Book EMMA = new Book(2, "Emma", "Jane Austen", 1815);
@Mock
BookService service;
@Mock
FacesContext facesContext;
@InjectMocks
CatalogueBean bean;
@Test
void loadsTheCatalogueWhenCreated() {
when(service.catalogue()).thenReturn(List.of(EMMA));
bean.load(); // the container would call the @PostConstruct method
assertEquals(List.of(EMMA), bean.getBooks());
}
@Test
void anEmptySearchAddsAWarningToTheQueryField() {
when(service.search("zzz")).thenReturn(List.of());
bean.setQuery("zzz");
bean.search();
ArgumentCaptor<FacesMessage> message = ArgumentCaptor.forClass(FacesMessage.class);
verify(facesContext).addMessage(eq("catalogue:query"), message.capture());
assertEquals(FacesMessage.SEVERITY_WARN, message.getValue().getSeverity());
assertEquals("No book matches 'zzz'.", message.getValue().getDetail());
}
@Test
void aSuccessfulSearchAddsNoMessage() {
when(service.search("emma")).thenReturn(List.of(EMMA));
bean.setQuery("emma");
bean.search();
assertEquals(List.of(EMMA), bean.getBooks());
verifyNoInteractions(facesContext);
}
@Test
void borrowingReportsToTheUser() {
bean.borrow(2);
verify(service).borrow(2, "reader");
ArgumentCaptor<FacesMessage> message = ArgumentCaptor.forClass(FacesMessage.class);
verify(facesContext).addMessage(eq(null), message.capture());
assertEquals("Borrowed book 2", message.getValue().getSummary());
}
@Test
void theViewStateSurvivesSerialisation() throws Exception {
// A view-scoped bean is serialised when the session is passivated or replicated.
// The injected collaborators are proxies, so only the state of the bean itself matters here.
CatalogueBean plain = new CatalogueBean();
plain.setQuery("emma");
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
out.writeObject(plain);
}
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
CatalogueBean copy = (CatalogueBean) in.readObject();
assertNotSame(plain, copy);
assertEquals("emma", copy.getQuery());
assertTrue(copy instanceof java.io.Serializable);
}
}
}
All five tests passed on 2026-10-08. What the test relies on, and what it deliberately leaves out:
-
@InjectMockswrites the two mocks into the private@Injectfields; the container is not involved, so the bean’s scope annotation (@ViewScoped) is inert in the test. Whether the scope is the right one is a question for the page and for the scopes page, not for the unit test. -
The test calls
load()itself because the container would call a@PostConstructmethod. The method is package-private on purpose; the test sits in the same package. -
ArgumentCaptorreads theFacesMessagethat the bean added. A message is the bean’s visible output, so the test asserts on its severity, summary or detail and on the client id it was attached to (catalogue:query, the id of the input inside the formcatalogue). -
verifyNoInteractions(facesContext)proves the happy path adds no message, which is a requirement stated in a test and not only in the code. -
The serialisation test checks that the view-scoped bean’s own state survives
ObjectOutputStream; a@ViewScopedbean must beSerializablebecause Faces keeps it with the view and an application server may passivate or replicate it (see the scopes page). The comment in the listing explains why the injected fields are not part of that test.
Source: Jakarta Faces 4.1 specification, 5.6.1 Jakarta Faces Objects Valid for @Inject Injection, B.4 Changes between 2.2 and 2.3 and Mockito
Code That Reaches for FacesContext.getCurrentInstance()
Older code, and helper classes in every code base, call the static FacesContext.getCurrentInstance(), which reads a thread-local that exists only while a Faces request is being processed. A unit test
that calls such code sees null. There are two remedies, in order of preference. The first is to inject the FacesContext into the class and pass it down, as CatalogueBean does. The second, for code that
cannot change, is Mockito.mockStatic.
package org.example.bookshelf.web;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
/** Static helper of the kind older code bases have: it reaches the context through the thread. */
public final class Messages {
private Messages() {
}
public static void info(String summary) {
FacesContext.getCurrentInstance()
.addMessage(null, new FacesMessage(FacesMessage.SEVERITY_INFO, summary, null));
}
}
package org.example.bookshelf.web;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
class MessagesTest {
@Test
void infoAddsAGlobalMessage() {
FacesContext context = mock(FacesContext.class);
// FacesContext.getCurrentInstance() is a static method that reads a thread-local:
// mockStatic replaces it for the duration of the try block, on this thread only.
try (MockedStatic<FacesContext> faces = mockStatic(FacesContext.class)) {
faces.when(FacesContext::getCurrentInstance).thenReturn(context);
Messages.info("Saved");
ArgumentCaptor<FacesMessage> message = ArgumentCaptor.forClass(FacesMessage.class);
verify(context).addMessage(isNull(), message.capture());
assertEquals("Saved", message.getValue().getSummary());
assertEquals(FacesMessage.SEVERITY_INFO, message.getValue().getSeverity());
}
}
}
The static mock is thread-local, so it does not leak into other tests, and it is closed by the try-with-resources. If the code under test starts its own thread, it sees the real static method again.
The Layer Below: Services and Events
The service that the bean calls is an @ApplicationScoped class with constructor injection and a jakarta.enterprise.event.Event it fires, as the backing beans page shows. The version in the test project adds a
search method and rejects a blank reader. Constructor injection is what makes it testable: the test builds it with mocks, with no CDI container at all.
package org.example.bookshelf.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import jakarta.enterprise.event.Event;
import java.util.List;
import java.util.Optional;
import org.example.bookshelf.model.Book;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class BookServiceTest {
@Mock
BookRepository repository;
@Mock
Event<BookBorrowed> borrowed;
@Test
void searchMatchesTitleOrAuthorIgnoringCase() {
when(repository.findAll()).thenReturn(List.of(
new Book(1, "Emma", "Jane Austen", 1815),
new Book(2, "Dracula", "Bram Stoker", 1897)));
BookService service = new BookService(repository, borrowed);
assertEquals(List.of("Emma"), service.search("AUSTEN").stream().map(Book::title).toList());
assertEquals(2, service.search(" ").size());
}
@Test
void borrowingFiresAnEvent() {
when(repository.findById(2)).thenReturn(Optional.of(new Book(2, "Emma", "Jane Austen", 1815)));
BookService service = new BookService(repository, borrowed);
service.borrow(2, "ana");
verify(borrowed).fire(new BookBorrowed(2, "ana"));
}
@Test
void borrowingNeedsAReader() {
BookService service = new BookService(repository, borrowed);
assertThrows(IllegalArgumentException.class, () -> service.borrow(2, " "));
verifyNoInteractions(repository);
verify(borrowed, never()).fire(org.mockito.ArgumentMatchers.any());
}
}
verifyNoInteractions(repository) and the never() verification state the other half of a rule: when the reader is missing, nothing is looked up and no event is fired. The observer side of the
event is tested differently: it needs CDI, and the Arquillian test of Integration Tests with Arquillian does it.
Unit-Testing Converters and Validators
Converters and validators run in Process Validations (the lifecycle page has the order: conversion first, then the validators, with required checked before them). Both are small classes with one
method, which makes them the best value for a unit test: every rule, every message and every edge case (null, blank, separators, lengths) runs in milliseconds.
The converter of the converters page ignores its FacesContext and UIComponent arguments, so the test passes null for both. When a converter reads component attributes, pass a mock
UIComponent and stub getAttributes().
package org.example.bookshelf.web;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.component.UIComponent;
import jakarta.faces.context.FacesContext;
import jakarta.faces.convert.Converter;
import jakarta.faces.convert.ConverterException;
import jakarta.faces.convert.FacesConverter;
import org.example.bookshelf.model.Isbn;
@FacesConverter(forClass = Isbn.class)
public class IsbnConverter implements Converter<Isbn> {
@Override
public Isbn getAsObject(FacesContext context, UIComponent component, String value) {
if (value == null || value.isBlank()) {
return null;
}
String digits = value.replaceAll("[-\\s]", "");
if (!digits.matches("\\d{13}")) {
FacesMessage message = new FacesMessage(FacesMessage.SEVERITY_ERROR,
"Not an ISBN-13", "'" + value + "' must contain exactly 13 digits (hyphens are allowed).");
throw new ConverterException(message);
}
return new Isbn(digits);
}
@Override
public String getAsString(FacesContext context, UIComponent component, Isbn value) {
return value == null ? "" : value.formatted();
}
}
package org.example.bookshelf.web;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.convert.ConverterException;
import org.example.bookshelf.model.Isbn;
import org.junit.jupiter.api.Test;
class IsbnConverterTest {
// The converter does not use its FacesContext and UIComponent arguments, so null is fine.
private final IsbnConverter converter = new IsbnConverter();
@Test
void stripsSeparators() {
Isbn isbn = converter.getAsObject(null, null, " 978-0-306-40615-7 ");
assertEquals("9780306406157", isbn.digits());
}
@Test
void emptyInputIsNull() {
assertNull(converter.getAsObject(null, null, ""));
assertNull(converter.getAsObject(null, null, " "));
}
@Test
void roundTrip() {
Isbn isbn = converter.getAsObject(null, null, "9780306406157");
assertEquals("978-0-306-40615-7", converter.getAsString(null, null, isbn));
}
@Test
void rejectsShortNumbersWithAnErrorMessage() {
ConverterException exception = assertThrows(ConverterException.class,
() -> converter.getAsObject(null, null, "12345"));
FacesMessage message = exception.getFacesMessage();
assertEquals("Not an ISBN-13", message.getSummary());
assertEquals(FacesMessage.SEVERITY_ERROR, message.getSeverity());
}
}
All four tests passed; this is the same test the converters page shows, now compiled and run. A ConverterException carries its FacesMessage (getFacesMessage()), so the test asserts on the message
that Faces would put on the page and not only on the exception type.
A validator that builds its message from the application’s resource bundle reaches it through context.getApplication().getResourceBundle(context, "msg"). The test mocks the two objects in that chain and
returns a ListResourceBundle with the one key the validator reads. Nothing else of Faces is needed, and the message text is asserted exactly.
package org.example.bookshelf.web;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.component.UIComponent;
import jakarta.faces.context.FacesContext;
import jakarta.faces.validator.FacesValidator;
import jakarta.faces.validator.Validator;
import jakarta.faces.validator.ValidatorException;
import java.text.MessageFormat;
import java.util.ResourceBundle;
@FacesValidator("bookshelf.isbnLength")
public class IsbnLengthValidator implements Validator<String> {
private static final int LENGTH = 13;
@Override
public void validate(FacesContext context, UIComponent component, String value) {
if (value == null || value.length() == LENGTH) {
return;
}
ResourceBundle bundle = context.getApplication().getResourceBundle(context, "msg");
String text = MessageFormat.format(bundle.getString("isbn.length"),
LENGTH, value, value.length());
throw new ValidatorException(new FacesMessage(FacesMessage.SEVERITY_ERROR, text, null));
}
}
package org.example.bookshelf.web;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import jakarta.faces.application.Application;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.faces.validator.ValidatorException;
import java.util.ListResourceBundle;
import java.util.ResourceBundle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class IsbnLengthValidatorTest {
private final IsbnLengthValidator validator = new IsbnLengthValidator();
private final FacesContext context = mock(FacesContext.class);
private final Application application = mock(Application.class);
@BeforeEach
void wireTheResourceBundle() {
ResourceBundle bundle = new ListResourceBundle() {
@Override
protected Object[][] getContents() {
return new Object[][] {{"isbn.length", "ISBN must have {0} digits, but \"{1}\" has {2}."}};
}
};
when(context.getApplication()).thenReturn(application);
when(application.getResourceBundle(eq(context), eq("msg"))).thenReturn(bundle);
}
@Test
void acceptsThirteenCharacters() {
assertDoesNotThrow(() -> validator.validate(context, null, "9780306406157"));
}
@Test
void nullIsLeftToRequired() {
assertDoesNotThrow(() -> validator.validate(context, null, null));
verifyNoInteractions(application);
}
@Test
void rejectsOtherLengthsWithTheLocalisedText() {
ValidatorException exception = assertThrows(ValidatorException.class,
() -> validator.validate(context, null, "12345"));
FacesMessage message = exception.getFacesMessage();
assertEquals("ISBN must have 13 digits, but \"12345\" has 5.", message.getSummary());
assertEquals(FacesMessage.SEVERITY_ERROR, message.getSeverity());
}
}
The test nullIsLeftToRequired records a convention of Faces validators: a validator does not report an empty value, because required does that, and verifyNoInteractions(application) proves it returns before it
touches the bundle. The @FacesValidator annotation itself, the id bookshelf.isbnLength and the way a page refers to it are tested by the page, not here (see Browser Tests).
Bean Validation Constraints
Constraints on a bean, such as the @Size(max = 200) of the review comment, are tested with the Bean Validation API and no Faces at all. This tests the constraint; the lifecycle’s use of it (the
f:validateBean tag, groups and the jakarta.faces.validator.DISABLE_DEFAULT_BEAN_VALIDATOR parameter, all on the Bean Validation page) is tested through the page.
package org.example.bookshelf.web;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import java.util.Set;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class ReviewBeanConstraintsTest {
private static ValidatorFactory factory;
private static Validator validator;
@BeforeAll
static void createValidator() {
factory = Validation.buildDefaultValidatorFactory();
validator = factory.getValidator();
}
@AfterAll
static void closeFactory() {
factory.close();
}
@Test
void aShortCommentIsValid() {
ReviewBean bean = new ReviewBean();
bean.setComment("Lovely.");
assertTrue(validator.validate(bean).isEmpty());
}
@Test
void aLongCommentViolatesTheSizeConstraint() {
ReviewBean bean = new ReviewBean();
bean.setComment("x".repeat(201));
Set<ConstraintViolation<ReviewBean>> violations = validator.validate(bean);
assertEquals(1, violations.size());
ConstraintViolation<ReviewBean> violation = violations.iterator().next();
assertEquals("comment", violation.getPropertyPath().toString());
assertEquals("size must be between 0 and 200", violation.getMessage());
}
}
Both tests passed. The ValidatorFactory is expensive to build, so it is created once per class and closed afterwards.
Components with the MyFaces Test Mock Objects
Between a plain unit test and a deployed page there is a middle height: real Faces component objects (UIInput, UIViewRoot) running with mock servlet and Faces objects around them. Apache MyFaces
publishes it as the myfaces-test artifact (org.apache.myfaces.core:myfaces-test, 4.1.4 on Maven Central, the same version as the Core). Its AbstractJsfTestCase builds the mock ServletContext,
HttpServletRequest, Application, ExternalContext, FacesContext, Lifecycle and RenderKit before each test (its setUp is annotated @BeforeEach, so the base class works with JUnit 5) and tears them down afterwards.
<dependency>
<groupId>org.apache.myfaces.core</groupId>
<artifactId>myfaces-api</artifactId>
<version>${myfaces.version}</version>
</dependency>
<dependency>
<groupId>org.apache.myfaces.core</groupId>
<artifactId>myfaces-impl</artifactId>
<version>${myfaces.version}</version>
</dependency>
<dependency>
<groupId>org.apache.myfaces.core</groupId>
<artifactId>myfaces-test</artifactId>
<version>${myfaces.version}</version>
<scope>test</scope>
</dependency>
<!-- what a servlet container normally provides -->
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.1.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-el-api</artifactId>
<version>${tomcat.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-jasper-el</artifactId>
<version>${tomcat.version}</version>
<scope>test</scope>
</dependency>
package org.example.bookshelf.web;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.component.UIInput;
import java.util.List;
import org.apache.myfaces.test.base.junit.AbstractJsfTestCase;
import org.example.bookshelf.model.Isbn;
import org.junit.jupiter.api.Test;
/**
* Real UIInput components running the real validation step, with mock servlet and Faces objects around them.
* The mock render kit has no renderers, so the component falls back to its own converter (and logs a warning).
*/
class IsbnInputTest extends AbstractJsfTestCase {
private UIInput input(String id) {
UIInput input = new UIInput();
input.setId(id);
facesContext.getViewRoot().getChildren().add(input);
return input;
}
@Test
void aValidIsbnIsConverted() {
UIInput input = input("isbn");
input.setConverter(new IsbnConverter());
input.setSubmittedValue("978-0-306-40615-7");
input.validate(facesContext);
assertTrue(input.isValid());
assertEquals(new Isbn("9780306406157"), input.getLocalValue());
assertTrue(facesContext.getMessageList().isEmpty());
}
@Test
void aBadIsbnProducesAConverterMessageForTheComponent() {
UIInput input = input("isbn");
input.setConverter(new IsbnConverter());
input.setSubmittedValue("12345");
input.validate(facesContext);
assertFalse(input.isValid());
List<FacesMessage> messages = facesContext.getMessageList("isbn");
assertEquals(1, messages.size());
assertEquals("Not an ISBN-13", messages.get(0).getSummary());
}
@Test
void validatorsRunOnTheConvertedValue() {
// A String validator on an input whose converter produces an Isbn fails with a ClassCastException:
// converters run first and the validator receives their result.
UIInput input = input("isbn");
input.setConverter(new IsbnConverter());
input.addValidator(new IsbnLengthValidator());
input.setSubmittedValue("978-0-306-40615-7");
org.junit.jupiter.api.Assertions.assertThrows(ClassCastException.class, () -> input.validate(facesContext));
}
@Test
void updateModelValuesWritesTheBean() {
// The mock application evaluates value expressions; request attributes play the role of the request scope.
ReviewBean bean = new ReviewBean();
request.setAttribute("reviewBean", bean);
UIInput comment = input("comment");
comment.setValueExpression("value", application.getExpressionFactory()
.createValueExpression(facesContext.getELContext(), "#{reviewBean.comment}", String.class));
comment.setSubmittedValue("Lovely.");
comment.validate(facesContext);
assertNull(bean.getComment()); // not yet: the model is updated in a later phase
comment.updateModel(facesContext);
assertEquals("Lovely.", bean.getComment());
}
}
All four tests passed with myfaces-api and myfaces-impl 4.1.4 on the class path. Three observations from the runs:
-
The mock render kit has no renderers, so every
UIInputlogs a warning "No Renderer found for component" and the component falls back to its own converter. The tests do not read rendered output, onlyisValid(), the local value and the messages. -
validatorsRunOnTheConvertedValuerecords the order the lifecycle uses: a converter runs first and the validators receive the converter’s result. A validator written forStringthat is attached to an input with theIsbnconverter fails with aClassCastException. The same mistake in a page appears as an HTTP 500 after the first postback, so a component-level test is where it is found cheaply. -
updateModelValuesWritesTheBeanshows the phases split: aftervalidatethe bean is still untouched, and onlyupdateModelwrites it. The mock application evaluates value expressions, and request attributes play the role of the request scope.
I ran the framework with MyFaces on the class path only. The mock classes are MyFaces' own, and I did not find a counterpart published by the Mojarra project; an application on Mojarra can still use this height with
the MyFaces jars as test dependencies, or use Mockito mocks as in Unit-Testing Backing Beans. Nothing in the framework replaces a deployed page: it has no Facelets compilation, no FacesServlet and no state saving.
Integration Tests with Arquillian
A unit test cannot tell whether CDI finds the bean, whether the event reaches its observer, or whether the page renders. Arquillian closes that gap: it packs a test archive with ShrinkWrap, starts a container (or connects to one) through an adapter, deploys the archive and runs the tests, in the container or against it. Each test class builds its own small deployment, so a test depends only on what it lists.
In-Container and Client Mode
The default mode is in-container: the test class is added to the archive, and the methods execute in the server’s JVM, where @Inject works on the test class itself. The second mode, client mode
(@Deployment(testable = false) or @RunAsClient), deploys the archive as it is and runs the test in the test JVM as a client of the container, which is the mode for web pages. One class can mix both.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-bom</artifactId>
<version>${selenium.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.jboss.arquillian.extension</groupId>
<artifactId>arquillian-drone-bom</artifactId>
<version>${drone.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.jboss.arquillian.graphene</groupId>
<artifactId>graphene-webdriver</artifactId>
<version>${graphene.version}</version>
<type>pom</type>
</dependency>
<dependency>
<groupId>org.jboss.arquillian</groupId>
<artifactId>arquillian-bom</artifactId>
<version>${arquillian.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.jboss.arquillian.junit5</groupId>
<artifactId>arquillian-junit5-container</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.tomee</groupId>
<artifactId>arquillian-tomee-embedded</artifactId>
<version>${tomee.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.tomee</groupId>
<artifactId>tomee-embedded</artifactId>
<version>${tomee.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<?xml version="1.0" encoding="UTF-8"?>
<arquillian xmlns="http://jboss.org/schema/arquillian"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://jboss.org/schema/arquillian http://jboss.org/schema/arquillian/arquillian_1_0.xsd">
<container qualifier="tomee" default="true">
<configuration>
<property name="httpPort">8753</property>
<property name="stopPort">8754</property>
<property name="dir">target/tomee-embedded</property>
<property name="appWorkingDir">target/arquillian-work</property>
</configuration>
</container>
<extension qualifier="webdriver">
<property name="browser">chrome</property>
<property name="chromeArguments">--headless=new --window-size=1280,900 --lang=en-US --remote-debugging-port=8756</property>
<property name="chromeDriverBinary">../se-cache/chromedriver/mac-arm64/155.0.8059.39/chromedriver</property>
</extension>
</arquillian>
The guide that the Arquillian site still lists for web tests, "Functional Testing using Drone and Graphene", was last updated on 21 September 2017 and uses JBoss AS 7 and the JUnit 4 runner
(@RunWith(Arquillian.class)). The idea is unchanged, but the code is not: with JUnit 5 the test class carries @ExtendWith(ArquillianExtension.class) from arquillian-junit5-container
(1.10.2.Final), and the Jakarta namespace has replaced javax.
package org.example.bookshelf.it;
import java.io.File;
import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.asset.EmptyAsset;
import org.jboss.shrinkwrap.api.asset.StringAsset;
import org.jboss.shrinkwrap.api.spec.WebArchive;
/** Packs only what the test needs: the classes, the bundle, the config files and the page under test. */
final class BookshelfDeployments {
private static final File WEBAPP = new File("../shelf/src/main/webapp");
/**
* A small page written for the test. It calls the record accessor as a method, {@code book.title()}:
* the embedded TomEE 10 is a Jakarta EE 10 server, whose Expression Language cannot read record components
* as properties ({@code book.title}), which EL 6.0 in Jakarta EE 11 can.
*/
private static final String TITLES = """
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:h="jakarta.faces.html"
xmlns:ui="jakarta.faces.facelets">
<h:head><title>Titles</title></h:head>
<h:body>
<h:form id="titles">
<ui:repeat value="#{catalogueBean.books}" var="book">
<p class="title">#{book.title()}</p>
</ui:repeat>
</h:form>
</h:body>
</html>
""";
/** The catalogue search with Ajax, written for EE 10 in the same way as {@link #TITLES}. */
private static final String SEARCH = """
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:h="jakarta.faces.html"
xmlns:f="jakarta.faces.core"
xmlns:ui="jakarta.faces.facelets">
<h:head><title>Search</title></h:head>
<h:body>
<h:form id="catalogue">
<h:inputText id="query" value="#{catalogueBean.query}"/>
<h:commandButton id="search" value="Search" action="#{catalogueBean.search}">
<f:ajax execute="@form" render="results"/>
</h:commandButton>
<h:panelGroup id="results" layout="block">
<h:messages/>
<ui:repeat value="#{catalogueBean.books}" var="book">
<p class="title">#{book.title()}</p>
</ui:repeat>
</h:panelGroup>
</h:form>
</h:body>
</html>
""";
private BookshelfDeployments() {
}
static WebArchive catalogue() {
return ShrinkWrap.create(WebArchive.class, "bookshelf.war")
.addPackages(true, "org.example.bookshelf")
.addAsResource(new File("../shelf/src/main/resources/org/example/bookshelf/messages.properties"),
"org/example/bookshelf/messages.properties")
.addAsWebInfResource(new File(WEBAPP, "WEB-INF/web.xml"))
.addAsWebInfResource(new File(WEBAPP, "WEB-INF/faces-config.xml"))
.addAsWebInfResource(new File(WEBAPP, "WEB-INF/templates/layout.xhtml"), "templates/layout.xhtml")
.addAsWebInfResource(EmptyAsset.INSTANCE, "beans.xml")
.addAsWebResource(new StringAsset(TITLES), "titles.xhtml")
.addAsWebResource(new StringAsset(SEARCH), "search.xhtml");
}
}
package org.example.bookshelf.it;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import java.util.ArrayList;
import java.util.List;
import org.example.bookshelf.service.BookBorrowed;
import org.example.bookshelf.service.BookService;
import org.jboss.arquillian.container.test.api.Deployment;
import org.jboss.arquillian.junit5.ArquillianExtension;
import org.jboss.shrinkwrap.api.spec.WebArchive;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
/** In-container mode: the test class is deployed with the application and CDI injects into it. */
@ExtendWith(ArquillianExtension.class)
class BookServiceIT {
@Deployment
static WebArchive deployment() {
return BookshelfDeployments.catalogue().addClass(BookServiceIT.class);
}
@Inject
BookService service;
static final List<BookBorrowed> seen = new ArrayList<>();
void onBorrowed(@Observes BookBorrowed event) {
seen.add(event);
}
@Test
void theContainerInjectsTheRealServiceAndTheRealEventBus() {
assertEquals(5, service.catalogue().size());
service.borrow(2, "ana");
assertEquals(1, seen.size());
assertTrue(seen.get(0).reader().equals("ana"));
}
}
package org.example.bookshelf.it;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.jboss.arquillian.container.test.api.Deployment;
import org.jboss.arquillian.container.test.api.RunAsClient;
import org.jboss.arquillian.junit5.ArquillianExtension;
import org.jboss.arquillian.test.api.ArquillianResource;
import org.jboss.shrinkwrap.api.spec.WebArchive;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
/** Client mode: the archive is deployed as it is and the test talks to it over HTTP from the test JVM. */
@ExtendWith(ArquillianExtension.class)
class CataloguePageIT {
@Deployment(testable = false)
static WebArchive deployment() {
return BookshelfDeployments.catalogue();
}
@ArquillianResource
URL base;
@Test
@RunAsClient
void theCatalogueIsRenderedByTheRealFacesServlet() throws Exception {
HttpResponse<String> response = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create(base + "titles.xhtml")).build(),
HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("Pride and Prejudice"));
assertTrue(response.body().contains("jakarta.faces.ViewState"));
}
}
All three Arquillian tests passed on TomEE 10.2.0 embedded: the service was injected and observed through real CDI, and the page was rendered by the container’s FacesServlet with a jakarta.faces.ViewState
field. @ArquillianResource URL is the base URL of the deployed web application, so the test needs no port. StringAsset writes a Facelets page straight into the archive; a test that needs a special page
(a fixture, a failing view) can create one without touching the application.
Source: Arquillian Core: test run modes and ShrinkWrap
Which Container
An adapter is a Maven dependency, and the choice of container decides which Faces implementation the test exercises. The adapters below exist on Maven Central on 2026-10-08; only the first was run.
| Container | Adapter | Notes |
|---|---|---|
Apache TomEE 10.2.0 embedded |
|
Run here. A Jakarta EE 10 server: MyFaces 4.0.3 (Faces 4.0), Expression Language 5.0. Starts in about a second in the test JVM. |
WildFly (managed or remote) |
|
Not run here. Mojarra (4.1.13 in WildFly 41.0.1); the closest to a production application server (see the servers page). |
CDI only |
|
Not run here. Boots Weld without a servlet container, so it can test CDI wiring and events but not Faces. |
GlassFish, Payara, Open Liberty |
Adapters maintained by each server’s project |
Not run here; see the documentation of the server. |
Four problems appeared while getting the TomEE run to work, and each is a mistake that other containers produce in a different form.
-
The API jar shadows the container’s. Adding
jakarta.jakartaee-api:10.0.0withprovidedscope putjakarta.faces-api4.0.1 in front of MyFaces' own API jar, and the deployment failed withNoClassDefFoundError: com/sun/faces/util/Utilraised fromjakarta.faces.CurrentThreadToServletContext. An embedded container supplies its own APIs; do not add a second set. -
The second error hides the first. After one failed deployment, the next test class failed with
Child name [/bookshelf] is not unique. Read the firstSEVEREline of the log, not the last stack trace. -
Records in Expression Language.
#{book.title}on theBookrecord threwPropertyNotFoundException: Property [title] not found on type [org.example.bookshelf.model.Book]on this Jakarta EE 10 server. Expression Language 6.0 (Jakarta EE 11) addsRecordELResolver, enabled by default; on EE 10 the page calls the accessor as a method,#{book.title()}, as the pages written for the test do. -
Noise. TomEE embedded logs
SEVERE: Unable to initialize agent: nulland an unused-property warning for ports it does not use; the tests passed with both messages present.
Browser Tests Inside Arquillian: Drone and Graphene
Arquillian Drone manages a WebDriver the way Arquillian manages a container (start, inject, stop), and Arquillian Graphene adds Ajax-aware waiting, page objects and page fragments on top of Selenium. Both have
a 3.0.1.Final release on Maven Central (April 2026 for the Drone BOM). Graphene 3.0.1.Final is built against Selenium 4.43.0; the module imports the Selenium BOM of the project (4.50.0) before the Drone BOM, and the test passed with
the newer Selenium. The browser is configured in arquillian.xml under the webdriver extension:
<extension qualifier="webdriver">
<property name="browser">chrome</property>
<property name="chromeArguments">--headless=new --window-size=1280,900 --lang=en-US --remote-debugging-port=8756</property>
<property name="chromeDriverBinary">../se-cache/chromedriver/mac-arm64/155.0.8059.39/chromedriver</property>
</extension>
The driver path above points into the Selenium Manager cache of this scratch project and is machine-specific; a real build leaves chromeDriverBinary out only if Drone finds a driver on its own, which was not tried here.
package org.example.bookshelf.it;
import static org.jboss.arquillian.graphene.Graphene.waitGui;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.net.URL;
import java.util.List;
import org.jboss.arquillian.container.test.api.Deployment;
import org.jboss.arquillian.container.test.api.RunAsClient;
import org.jboss.arquillian.drone.api.annotation.Drone;
import org.jboss.arquillian.graphene.Graphene;
import org.jboss.arquillian.junit5.ArquillianExtension;
import org.jboss.arquillian.test.api.ArquillianResource;
import org.jboss.shrinkwrap.api.spec.WebArchive;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
/** Drone starts and stops the browser and injects it; Graphene adds the waits for Ajax and page objects. */
@ExtendWith(ArquillianExtension.class)
@RunAsClient
class CatalogueGrapheneIT {
@Deployment(testable = false)
static WebArchive deployment() {
return BookshelfDeployments.catalogue();
}
@Drone
WebDriver browser;
@ArquillianResource
URL base;
@FindBy(id = "catalogue:query")
WebElement query;
@FindBy(id = "catalogue:search")
WebElement search;
@Test
void searchFiltersTheCatalogueWithAjax() {
browser.get(base + "search.xhtml");
query.sendKeys("austen");
// Graphene.guardAjax runs the click, then fails unless an Ajax request was started and has finished
Graphene.guardAjax(search).click();
List<WebElement> titles = browser.findElements(By.cssSelector("p.title"));
assertEquals(2, titles.size());
waitGui().until(d -> d.findElement(By.cssSelector("p.title")).getText().equals("Pride and Prejudice"));
}
}
The test passed on TomEE embedded. Graphene.guardAjax(search).click() returns after the click and fails unless an Ajax request was started and has finished, which replaces both Thread.sleep and the
hand-written wait of Waiting for Ajax. @FindBy fields on the test class are proxies that Graphene injects and resolves at each use. It is worth adding when the suite already runs on Arquillian; otherwise Selenium or Playwright alone is simpler.
Browser Tests
A browser test starts the application, opens a page in a real browser, acts like a user and reads what is on the screen. It exercises what nothing below it can: faces.js, the component library’s JavaScript, the DOM that the renderers produce,
focus, dialogs and timing. It is also the slowest height, so the Bookshelf suite keeps it for flows (search, review, borrow, dialog) and not for single rules.
A Server for the Tests
The e2e module starts the exploded WAR in an embedded Tomcat 11.0.26 once per JVM and hands out its URL. The WAR is unpacked into target/webapp by the dependency plugin, so the tests run the same bytes that the
build packaged, with Weld bundled in the WAR as in the getting started page and the Faces implementation chosen by the Maven profile. -Dbookshelf.url=... points the same tests at an
application that runs somewhere else (a staging server), and the project stage is Production unless -Dbookshelf.stage says otherwise, so the tests run with the settings the users get.
package org.example.bookshelf.e2e;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import org.apache.catalina.Context;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.startup.Tomcat;
/** Starts the unpacked WAR on an embedded Tomcat once per JVM and hands out its base URL. */
public final class BookshelfServer {
private static Tomcat tomcat;
private static String baseUrl;
/** A second, separately configured application: its own port, context parameters and lifetime. */
public record Running(Tomcat tomcat, String url) implements AutoCloseable {
@Override
public void close() {
try {
tomcat.stop();
tomcat.destroy();
} catch (LifecycleException ignored) {
// shutting down
}
}
}
public static Running start(int port, Map<String, String> contextParameters) {
try {
Path base = Files.createTempDirectory("bookshelf-tomcat");
Tomcat server = new Tomcat();
server.setBaseDir(base.toString());
server.setPort(port);
server.getServer().setPort(-1);
server.getConnector();
Context context = server.addWebapp("/bookshelf", new File("target/webapp").getAbsolutePath());
contextParameters.forEach(context::addParameter);
server.start();
return new Running(server, "http://localhost:" + port + "/bookshelf");
} catch (Exception e) {
throw new IllegalStateException("Cannot start the application", e);
}
}
private BookshelfServer() {
}
public static synchronized String url() {
if (baseUrl == null) {
// -Dbookshelf.url=... tests an application that is already running somewhere else
baseUrl = System.getProperty("bookshelf.url");
if (baseUrl == null) {
baseUrl = startEmbedded();
}
}
return baseUrl;
}
private static String startEmbedded() {
int port = Integer.getInteger("bookshelf.port", 8750);
try {
Path base = Files.createTempDirectory("bookshelf-tomcat");
tomcat = new Tomcat();
tomcat.setBaseDir(base.toString());
tomcat.setPort(port);
tomcat.getServer().setPort(-1); // no shutdown port
tomcat.getConnector();
Context context = tomcat.addWebapp("/bookshelf", new File("target/webapp").getAbsolutePath());
context.addParameter("jakarta.faces.PROJECT_STAGE", System.getProperty("bookshelf.stage", "Production"));
tomcat.start();
Runtime.getRuntime().addShutdownHook(new Thread(BookshelfServer::stop));
return "http://localhost:" + port + "/bookshelf";
} catch (Exception e) {
throw new IllegalStateException("Cannot start the application", e);
}
}
private static synchronized void stop() {
try {
if (tomcat != null) {
tomcat.stop();
tomcat.destroy();
}
} catch (LifecycleException ignored) {
// shutting down
}
}
}
A stale target/webapp caused the one confusing failure of the setup. The unpack goal adds to the directory, so after a run with -Pmyfaces a run with Mojarra contained both implementations, and Weld refused to
start with WELD-001414: Bean name is ambiguous. Name requestScope resolves to beans (Mojarra’s and MyFaces' producers of the same name). Switching profile needs mvn clean.
Finding Elements on a Faces Page
Three properties of Faces markup decide how a test finds an element.
-
Client ids carry the naming containers. The
h:inputText id="query"insideh:form id="catalogue"is rendered with the idcatalogue:query; a data table row adds the row index (catalogue:books:0:borrow).By.id("catalogue:query")takes the id as it is. In a CSS selector the colon must be escaped, so the tests use an attribute selector,[id='catalogue:books']. -
Labels are real.
h:outputLabel for="isbn"renders<label for="review:isbn">, so Playwright’sgetByLabel("ISBN")works and doubles as an accessibility check. -
Passthrough attributes make stable hooks.
pt:data-testid="save-review"(namespacejakarta.faces.passthrough) adds adata-testidattribute to the HTML element without a renderer or an id, and it survives a refactoring that moves the component into another form or a composite component. Locating by test id is the documented default in Playwright (getByTestId).
<ui:composition xmlns="http://www.w3.org/1999/xhtml"
xmlns:h="jakarta.faces.html"
xmlns:f="jakarta.faces.core"
xmlns:ui="jakarta.faces.facelets"
xmlns:pt="jakarta.faces.passthrough"
template="/WEB-INF/templates/layout.xhtml">
<ui:define name="content">
<h1>Catalogue</h1>
<h:form id="catalogue">
<h:outputLabel for="query" value="Search"/>
<h:inputText id="query" value="#{catalogueBean.query}" pt:data-testid="query"/>
<h:commandButton id="search" value="#{msg['catalogue.search']}" action="#{catalogueBean.search}"
pt:data-testid="search">
<f:ajax execute="@form" render="books messages"/>
</h:commandButton>
<h:messages id="messages" globalOnly="false" errorClass="error" infoClass="info"
warnClass="warn"/>
<h:dataTable id="books" value="#{catalogueBean.books}" var="book">
<h:column><f:facet name="header">Title</f:facet><h:outputText value="#{book.title}" styleClass="title"/></h:column>
<h:column><f:facet name="header">Author</f:facet>#{book.author}</h:column>
<h:column><f:facet name="header">Year</f:facet>#{book.year}</h:column>
<h:column>
<h:commandButton id="borrow" value="Borrow" action="#{catalogueBean.borrow(book.id)}"
pt:data-testid="borrow-#{book.id}">
<f:ajax execute="@this" render=":catalogue:messages"/>
</h:commandButton>
</h:column>
</h:dataTable>
</h:form>
</ui:define>
</ui:composition>
<ui:composition xmlns="http://www.w3.org/1999/xhtml"
xmlns:h="jakarta.faces.html"
xmlns:f="jakarta.faces.core"
xmlns:ui="jakarta.faces.facelets"
xmlns:pt="jakarta.faces.passthrough"
template="/WEB-INF/templates/layout.xhtml">
<ui:define name="content">
<h1>Review a book</h1>
<h:form id="review">
<h:messages id="messages" errorClass="error" infoClass="info"/>
<h:outputLabel for="isbn" value="ISBN"/>
<h:inputText id="isbn" value="#{reviewBean.isbn}" required="true" validator="bookshelf.isbnLength"
pt:data-testid="isbn"/>
<h:outputLabel for="rating" value="Rating"/>
<h:inputText id="rating" value="#{reviewBean.rating}" required="true" pt:data-testid="rating">
<f:validateLongRange minimum="1" maximum="5"/>
</h:inputText>
<h:outputLabel for="comment" value="Comment"/>
<h:inputTextarea id="comment" value="#{reviewBean.comment}" pt:data-testid="comment"/>
<h:commandButton id="submit" value="Save" action="#{reviewBean.submit}" pt:data-testid="save-review">
<f:ajax execute="@form" render="@form"/>
</h:commandButton>
<h:outputText id="saved" value="#{reviewBean.saved}" pt:data-testid="saved"/>
</h:form>
</ui:define>
</ui:composition>
Source: Jakarta Faces 4.1 VDL: h:form, f:ajax and Jakarta EE Tutorial: Facelets (passthrough attributes)
An empty h:dataTable still renders one filler row, so counting tr elements after a search that finds nothing gives 1, not 0. The tests count the cells that carry the style class title instead.
Waiting for Ajax
The most common reason for a flaky Faces browser test is reading the page before the Ajax response has been applied. A fixed sleep is slow when the server is fast and wrong when it is slow, and Selenium’s implicit wait only waits for
elements to exist, not for an element’s content to change. The reliable signal is Faces' own: faces.ajax.addOnEvent reports begin and success of every request, and faces.ajax.addOnError reports failures (the Ajax page
documents both). The helper counts them and the test waits until none is in flight.
package org.example.bookshelf.e2e;
import java.time.Duration;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.support.ui.WebDriverWait;
/** Knows when a Jakarta Faces Ajax request has finished: counts the events faces.ajax reports. */
public final class FacesAjax {
private static final String INSTALL = """
if (!window.__ajax) {
window.__ajax = { pending: 0, finished: 0, errors: [] };
faces.ajax.addOnEvent(function (event) {
if (event.status === 'begin') { window.__ajax.pending++; }
if (event.status === 'success') { window.__ajax.pending--; window.__ajax.finished++; }
});
faces.ajax.addOnError(function (error) {
window.__ajax.pending--;
window.__ajax.errors.push(error.status + ': ' + error.description);
});
}
""";
private FacesAjax() {
}
/** Call once after each full page load (a full page load discards the script's state). */
public static void install(WebDriver driver) {
((JavascriptExecutor) driver).executeScript(INSTALL);
}
/** Waits until no Faces Ajax request is in flight. Call it right after the action that starts one. */
public static void waitIdle(WebDriver driver) {
new WebDriverWait(driver, Duration.ofSeconds(10)).until(d -> {
Object pending = ((JavascriptExecutor) d).executeScript("return window.__ajax ? window.__ajax.pending : -1");
return Long.valueOf(0).equals(pending);
});
}
public static long finished(WebDriver driver) {
return (Long) ((JavascriptExecutor) driver).executeScript("return window.__ajax.finished");
}
}
The script state lives in the page, so install must run after every full page load (the base class does it in open). finished(driver) also proves that a test stayed on one page: after a search the counter was 1, which
a full reload would have reset to 0.
The helper listens to the standard faces.ajax events, which f:ajax uses on both implementations. PrimeFaces components send their requests through the library’s own jQuery-based client, and a page that
contains only PrimeFaces components may not define a global faces at all, so the helper is for plain Faces pages and the PrimeFaces guards of PrimeFaces Selenium are for PrimeFaces ones.
Selenium WebDriver
Selenium 4.50.0 resolves the browser driver on its own through Selenium Manager (no WebDriverManager or webdriver.chrome.driver property); the SE_CACHE_PATH environment variable in the Failsafe
configuration only keeps the downloaded driver inside the project. The tests start one headless Chrome per class with fixed ports, so two builds on one machine do not collide.
package org.example.bookshelf.e2e;
import java.util.Map;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeDriverService;
import org.openqa.selenium.chrome.ChromeOptions;
/** A headless Chrome whose ports and language are fixed, so that parallel builds do not collide. */
public final class PinnedChrome {
private PinnedChrome() {
}
public static WebDriver create() {
ChromeOptions options = new ChromeOptions();
options.addArguments("--headless=new", "--window-size=1280,900", "--lang=en-US",
"--remote-debugging-port=" + Integer.getInteger("chrome.debug.port", 8752));
options.setExperimentalOption("prefs", Map.of("intl.accept_languages", "en-US"));
ChromeDriverService service = new ChromeDriverService.Builder()
.usingPort(Integer.getInteger("webdriver.port", 8751))
.build();
return new ChromeDriver(service, options);
}
}
package org.example.bookshelf.e2e;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.openqa.selenium.WebDriver;
/** One headless Chrome per test class. */
abstract class BrowserTest {
protected static WebDriver driver;
protected static String base;
@BeforeAll
static void startBrowser() {
base = BookshelfServer.url();
driver = PinnedChrome.create();
}
@AfterAll
static void stopBrowser() {
if (driver != null) {
driver.quit();
}
}
protected static void open(String path) {
driver.get(base + path);
FacesAjax.install(driver);
}
}
package org.example.bookshelf.e2e;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
class CatalogueSeleniumIT extends BrowserTest {
// Ids of components inside a form are prefixed with the form id: catalogue:query.
// By.id takes the id as it is; in a CSS selector the colon must be escaped, so use an attribute selector.
private static final By QUERY = By.id("catalogue:query");
private static final By SEARCH = By.cssSelector("[data-testid='search']");
// Only the cells that carry the class title: an empty h:dataTable still renders one filler row.
private static final By ROWS = By.cssSelector("[id='catalogue:books'] .title");
private static final By MESSAGES = By.id("catalogue:messages");
@BeforeEach
void openTheCatalogue() {
open("/index.xhtml");
}
@Test
void theCatalogueListsAllTheBooks() {
assertEquals(5, driver.findElements(ROWS).size());
}
@Test
void searchNarrowsTheTableWithoutReloadingThePage() {
driver.findElement(QUERY).sendKeys("austen");
driver.findElement(SEARCH).click();
FacesAjax.waitIdle(driver);
assertEquals(2, driver.findElements(ROWS).size());
assertEquals(1, FacesAjax.finished(driver)); // the same page: the counter survived
}
@Test
void anEmptySearchShowsAWarning() {
driver.findElement(QUERY).sendKeys("zzz");
driver.findElement(SEARCH).click();
FacesAjax.waitIdle(driver);
assertEquals(0, driver.findElements(ROWS).size());
assertTrue(driver.findElement(MESSAGES).getText().contains("Nothing found"));
}
@Test
void borrowingABookShowsAConfirmation() {
driver.findElement(By.cssSelector("[data-testid='borrow-2']")).click();
FacesAjax.waitIdle(driver);
assertEquals("Borrowed book 2", driver.findElement(MESSAGES).getText().strip());
}
@Test
void rowsKeepTheirContentAfterAnAjaxUpdate() {
List<String> before = driver.findElements(ROWS).stream().map(row -> row.getText()).toList();
driver.findElement(By.cssSelector("[data-testid='borrow-1']")).click();
FacesAjax.waitIdle(driver);
assertEquals(before, driver.findElements(ROWS).stream().map(row -> row.getText()).toList());
}
@Test
void theBrowserKnowsWhichProjectStageItIsTesting() {
// faces.getProjectStage() returns Application.getProjectStage() without a request to the server
Object stage = ((org.openqa.selenium.JavascriptExecutor) driver).executeScript("return faces.getProjectStage();");
assertEquals("Production", stage);
}
}
package org.example.bookshelf.e2e;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
class ReviewSeleniumIT extends BrowserTest {
private static final By ISBN = By.id("review:isbn");
private static final By RATING = By.id("review:rating");
private static final By SAVE = By.cssSelector("[data-testid='save-review']");
private static final By MESSAGES = By.id("review:messages");
private static final By SAVED = By.cssSelector("[data-testid='saved']");
@BeforeEach
void openTheForm() {
open("/review.xhtml");
}
private void fill(String isbn, String rating) {
driver.findElement(ISBN).sendKeys(isbn);
driver.findElement(RATING).sendKeys(rating);
}
@Test
void aShortIsbnIsRejectedWithTheLocalisedMessage() {
fill("12345", "4");
driver.findElement(SAVE).click();
FacesAjax.waitIdle(driver);
assertTrue(driver.findElement(MESSAGES).getText().contains("ISBN must have 13 digits, but \"12345\" has 5."));
assertEquals("", driver.findElement(SAVED).getText());
}
@Test
void anOutOfRangeRatingIsRejected() {
fill("9780306406157", "9");
driver.findElement(SAVE).click();
FacesAjax.waitIdle(driver);
assertTrue(driver.findElement(MESSAGES).getText().contains("not between the expected values of 1 and 5"));
}
@Test
void aValidReviewIsSavedAndTheFormIsKept() {
fill("9780306406157", "5");
driver.findElement(SAVE).click();
FacesAjax.waitIdle(driver);
assertEquals("Saved review 5/5 for 9780306406157", driver.findElement(SAVED).getText());
assertEquals("9780306406157", driver.findElement(ISBN).getAttribute("value"));
}
@Test
void theViewStateFieldSurvivesTheAjaxUpdate() {
String before = viewState();
fill("9780306406157", "5");
driver.findElement(SAVE).click();
FacesAjax.waitIdle(driver);
// With server-side state saving the id of the saved view stays the same for the life of the view
assertEquals(before, viewState());
}
private String viewState() {
return driver.findElement(By.cssSelector("input[name='jakarta.faces.ViewState']")).getAttribute("value");
}
}
All ten tests of the two classes passed on Mojarra 4.1.17 and on MyFaces 4.1.4. theViewStateFieldSurvivesTheAjaxUpdate asserts that the jakarta.faces.ViewState field has the same value after the Ajax update as before it,
which holds with server-side state saving (the default) on both; Testing Ajax and the View State Without a Browser covers the protocol itself. theBrowserKnowsWhichProjectStageItIsTesting reads faces.getProjectStage() (specification 14.5), which returns the server’s project stage without a network call.
Playwright for Java
Playwright for Java 1.63.0 has the advantage that matters most on Ajax pages: every action waits for the element to be actionable, and the assertThat assertions retry until they pass or time out. The tests below have no explicit wait for the
Ajax request, because the assertion on the result is the wait.
package org.example.bookshelf.e2e;
import static com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.BrowserType;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import com.microsoft.playwright.Request;
import com.microsoft.playwright.Response;
import java.nio.file.Path;
import java.util.regex.Pattern;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class ReviewPlaywrightIT {
private static Playwright playwright;
private static Browser browser;
private BrowserContext context;
private Page page;
@BeforeAll
static void launch() {
playwright = Playwright.create();
// channel "chrome" drives the Chrome that is installed; without it Playwright uses its own download
browser = playwright.chromium().launch(new BrowserType.LaunchOptions().setChannel("chrome").setHeadless(true));
}
@AfterAll
static void close() {
browser.close();
playwright.close();
}
@BeforeEach
void openTheForm() {
context = browser.newContext(new Browser.NewContextOptions().setLocale("en-US"));
context.tracing().start(new com.microsoft.playwright.Tracing.StartOptions().setScreenshots(true).setSnapshots(true));
page = context.newPage();
page.navigate(BookshelfServer.url() + "/review.xhtml");
}
@AfterEach
void closeTheContext(org.junit.jupiter.api.TestInfo info) {
context.tracing().stop(new com.microsoft.playwright.Tracing.StopOptions()
.setPath(Path.of("target", "traces", info.getTestMethod().orElseThrow().getName() + ".zip")));
context.close();
}
@Test
void aShortIsbnIsRejected() {
page.getByLabel("ISBN").fill("12345");
page.getByLabel("Rating").fill("4");
page.getByTestId("save-review").click();
// The assertion retries until it passes or times out: no explicit wait for the Ajax request
assertThat(page.locator("[id='review:messages']"))
.containsText("ISBN must have 13 digits, but \"12345\" has 5.");
assertThat(page.getByTestId("saved")).hasText("");
}
@Test
void aValidReviewIsSaved() {
page.getByTestId("isbn").fill("9780306406157");
page.getByTestId("rating").fill("5");
page.getByTestId("save-review").click();
assertThat(page.getByTestId("saved")).hasText("Saved review 5/5 for 9780306406157");
assertThat(page.getByTestId("isbn")).hasValue("9780306406157");
}
@Test
void thePartialResponseIsXmlThatUpdatesTheForm() {
page.getByTestId("isbn").fill("9780306406157");
page.getByTestId("rating").fill("5");
Response response = page.waitForResponse(
r -> r.request().method().equals("POST")
&& r.request().postData().contains("jakarta.faces.partial.ajax=true"),
() -> page.getByTestId("save-review").click());
Request request = response.request();
assertTrue(request.headers().get("faces-request").equals("partial/ajax"));
assertTrue(response.headers().get("content-type").startsWith("text/xml"));
assertTrue(response.text().contains("<partial-response"));
assertTrue(response.text().contains("<update id=\"review\">"));
assertTrue(Pattern.compile("Saved review 5/5").matcher(response.text()).find());
}
}
All three tests passed on both implementations. Notes on the setup: setChannel("chrome") drives the Chrome that is installed instead of a browser that Playwright downloads, and the environment variable
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 (set in the Failsafe configuration) stops the library from downloading its own. The context records a trace for each test into target/traces/<test>.zip, which the Trace Viewer opens after a failure to
show each step with a DOM snapshot and a screenshot. The third test shows when a wait is still needed: to read the Ajax response the test uses page.waitForResponse around the click, and checks the headers Faces-Request: partial/ajax and
Content-Type: text/xml and the <update id="review"> element.
PrimeFaces Selenium
PrimeFaces publishes its own test library, built on JUnit 5 and Selenium and "heavily inspired by Arquillian Graphene" in the words of its README: page objects, typed wrappers for PrimeFaces components, and Ajax guards that know the PrimeFaces client.
Its Maven artifacts are org.primefaces:primefaces-selenium-core and org.primefaces:primefaces-selenium-components, both 16.0.0 on Maven Central (the README’s snippet still shows 15.0.0). The components artifact has 55 component classes in its 16.0.0
sources, among them DataTable, Dialog, CommandButton, InputText, SelectOneMenu, AutoComplete, DatePicker, FileUpload, TabView and Tree.
The library needs two things from the project: a configuration file, src/test/resources/primefaces-selenium/config.properties, and a DeploymentAdapter that starts the application and returns its URL.
deployment.adapter=org.example.bookshelf.e2e.BookshelfDeployment
webdriver.adapter=org.example.bookshelf.e2e.PinnedChromeAdapter
webdriver.browser=chrome
webdriver.headless=true
timeout.ajax=10
| Key | Default | Meaning |
|---|---|---|
|
none |
The URL of a running application (remote testing). Either this or |
|
none |
A class implementing |
|
built-in |
A class implementing |
|
none, |
The browser, headless mode, the driver version and the level of browser console messages that are collected. |
|
2, 10, 10, 15, 20 (seconds) |
Waits of the GUI, Ajax and HTTP guards, document load and file upload. |
|
|
Turns the component library’s animations off during the test, a large source of flakiness. |
|
none |
Where a screenshot and a text file with the stack trace are saved when a test fails. |
A value of the form ${NAME} is resolved from a system property, then from an environment variable, and a system property named like the key overrides the file, so CI can change the browser without editing it.
package org.example.bookshelf.e2e;
import org.primefaces.selenium.spi.DeploymentAdapter;
/** Tells PrimeFaces Selenium how to start the application and where it is. */
public class BookshelfDeployment implements DeploymentAdapter {
@Override
public void startup() {
BookshelfServer.url();
}
@Override
public String getBaseUrl() {
return BookshelfServer.url() + "/"; // PrimeFaces Selenium appends the page location
}
@Override
public void shutdown() {
// the server stops with the JVM
}
}
package org.example.bookshelf.e2e;
import org.openqa.selenium.WebDriver;
import org.primefaces.selenium.internal.ConfigProvider;
import org.primefaces.selenium.spi.WebDriverAdapter;
/** Replaces the default adapter, which downloads a driver with WebDriverManager and picks free ports. */
public class PinnedChromeAdapter implements WebDriverAdapter {
@Override
public void initialize(ConfigProvider configProvider) {
// nothing to download: Selenium Manager resolves the driver
}
@Override
public WebDriver createWebDriver() {
return PinnedChrome.create();
}
}
The project replaced the web driver adapter because the built-in one downloads a driver with WebDriverManager and does not fix the ports; the replacement reuses the Selenium Manager based Chrome of the other tests. The page under test is the PrimeFaces variant of the catalogue.
<ui:composition xmlns="http://www.w3.org/1999/xhtml"
xmlns:h="jakarta.faces.html"
xmlns:f="jakarta.faces.core"
xmlns:ui="jakarta.faces.facelets"
xmlns:pt="jakarta.faces.passthrough"
xmlns:p="primefaces"
template="/WEB-INF/templates/layout.xhtml">
<ui:define name="content">
<h1>Shelf (PrimeFaces)</h1>
<h:form id="shelf">
<p:growl id="growl" life="30000"/>
<p:dataTable id="books" widgetVar="booksTable" value="#{catalogueBean.books}" var="book"
paginator="true" rows="2" paginatorPosition="bottom">
<p:column headerText="Title"><h:outputText value="#{book.title}"/></p:column>
<p:column headerText="Author"><h:outputText value="#{book.author}"/></p:column>
<p:column headerText="Borrow">
<p:commandButton id="borrow" value="Borrow" action="#{catalogueBean.borrow(book.id)}"
process="@this" update=":shelf:growl" pt:data-testid="borrow-#{book.id}"/>
</p:column>
</p:dataTable>
<p:commandButton id="open" type="button" value="Review" onclick="PF('reviewDialog').show()"
pt:data-testid="open-review"/>
<p:dialog id="dialog" widgetVar="reviewDialog" header="Review a book" modal="true" showEffect="none"
hideEffect="none">
<h:panelGrid columns="2">
<p:outputLabel for="isbn" value="ISBN"/>
<p:inputText id="isbn" value="#{reviewBean.isbn}" required="true" validator="bookshelf.isbnLength"
pt:data-testid="isbn"/>
<p:outputLabel for="genre" value="Genre"/>
<p:selectOneMenu id="genre" widgetVar="genreMenu" value="#{catalogueBean.query}">
<f:selectItem itemLabel="Any" itemValue=""/>
<f:selectItem itemLabel="Gothic" itemValue="gothic"/>
<f:selectItem itemLabel="Romance" itemValue="romance"/>
</p:selectOneMenu>
</h:panelGrid>
<p:commandButton id="save" value="Save" action="#{reviewBean.submit}" update="growl isbn"
oncomplete="if (!args.validationFailed) PF('reviewDialog').hide()"
pt:data-testid="save-review"/>
</p:dialog>
</h:form>
</ui:define>
</ui:composition>
package org.example.bookshelf.e2e;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.primefaces.selenium.AbstractPrimePage;
import org.primefaces.selenium.component.CommandButton;
import org.primefaces.selenium.component.DataTable;
import org.primefaces.selenium.component.Dialog;
import org.primefaces.selenium.component.InputText;
import org.primefaces.selenium.component.SelectOneMenu;
/** The page object of shelf.xhtml: one typed field per component, found by the client id. */
public class ShelfPage extends AbstractPrimePage {
@FindBy(id = "shelf:books")
DataTable books;
@FindBy(id = "shelf:open")
CommandButton openReview;
@FindBy(id = "shelf:dialog")
Dialog dialog;
@FindBy(id = "shelf:isbn")
InputText isbn;
@FindBy(id = "shelf:genre")
SelectOneMenu genre;
@FindBy(id = "shelf:save")
CommandButton save;
// p:growl renders its messages into a container next to the form, not inside the component's own element
@FindBy(css = "[id='shelf:growl_container'] .ui-growl-message")
WebElement growlMessage;
@Override
public String getLocation() {
return "shelf.xhtml";
}
}
package org.example.bookshelf.e2e;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import org.primefaces.selenium.AbstractPrimePageTest;
import org.primefaces.selenium.PrimeSelenium;
class ShelfPrimeIT extends AbstractPrimePageTest {
@Test
void theTablePagesTwoRowsAtATime(ShelfPage page) {
assertEquals(2, page.books.getRows().size());
assertEquals("Pride and Prejudice", page.books.getCell(0, 0).getWebElement().getText());
page.books.selectPage(2); // an Ajax request: the library waits for it
assertEquals("Frankenstein", page.books.getCell(0, 0).getWebElement().getText());
}
@Test
void borrowingShowsAGrowlMessage(ShelfPage page) {
PrimeSelenium.guardAjax(page.books.getRow(1).getWebElement().findElement(By.tagName("button"))).click();
assertEquals("Borrowed book 2", page.growlMessage.getText().strip());
}
@Test
void theDialogStaysOpenWhileTheIsbnIsInvalid(ShelfPage page) {
page.openReview.clickUnguarded(); // type="button": opens the dialog on the client only
PrimeSelenium.waitGui().until(d -> page.dialog.isVisible());
page.isbn.setValue("12345");
page.save.click();
assertTrue(page.dialog.isVisible());
assertTrue(page.growlMessage.getText().contains("ISBN must have 13 digits, but \"12345\" has 5."));
page.isbn.setValue("9780306406157");
page.save.click();
PrimeSelenium.waitGui().until(d -> !page.dialog.isVisible());
assertFalse(page.dialog.isVisible());
}
@Test
void theMenuSelectionIsReadBack(ShelfPage page) {
page.openReview.clickUnguarded();
PrimeSelenium.waitGui().until(d -> page.dialog.isVisible());
page.genre.select("Gothic");
assertEquals("Gothic", page.genre.getSelectedLabel());
}
}
All four tests passed on both implementations. What the base class gives: AbstractPrimePageTest registers four JUnit 5 extensions (bootstrap, web driver, page injection, screenshot on failure) and injects the page
object as a parameter of the test method, so no field or @BeforeEach is needed; it also offers assertNoJavascriptErrors(), which fails the test if the browser console has SEVERE messages. In the tests:
-
page.books.selectPage(2)andpage.save.click()wait for the Ajax request on their own: the library wraps component actions in a guard that waits until the document is loaded, no jQuery request is active, the PrimeFaces Ajax queue is empty and animations are done. -
A button with
type="button"opens the dialog on the client and sends no request, soclickUnguarded()is the call for it, andPrimeSelenium.waitGui()waits for the dialog to be visible. -
PrimeSelenium.guardAjax(element).click()guards any plainWebElement, as the borrow test does for a button inside a table row. -
p:growlwrites its messages into a container next to the form (shelf:growl_container), not inside the component’s own element, which is why the page object selects.ui-growl-messageunder that container.
HtmlUnit: A Browser Written in Java
HtmlUnit (org.htmlunit:htmlunit 5.5.0) is a headless browser model in Java: it needs no browser binary and starts in milliseconds, and it runs the page’s JavaScript with its own engine. That is its weakness on
Faces pages, because the engine is not Chrome’s.
package org.example.bookshelf.e2e;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.htmlunit.BrowserVersion;
import org.htmlunit.WebClient;
import org.htmlunit.html.HtmlPage;
import org.htmlunit.html.HtmlSubmitInput;
import org.htmlunit.html.HtmlTextInput;
import org.junit.jupiter.api.Test;
/** HtmlUnit is a browser written in Java: no browser binary, but a JavaScript engine that is not Chrome's. */
class ReviewHtmlUnitIT {
@Test
void aValidReviewIsSavedThroughAjax() throws Exception {
try (WebClient client = new WebClient(BrowserVersion.CHROME)) {
HtmlPage page = client.getPage(BookshelfServer.url() + "/review.xhtml");
page.<HtmlTextInput>getHtmlElementById("review:isbn").type("9780306406157");
page.<HtmlTextInput>getHtmlElementById("review:rating").type("5");
page.<HtmlSubmitInput>getHtmlElementById("review:submit").click();
client.waitForBackgroundJavaScript(5_000); // lets the Ajax request finish
assertEquals("Saved review 5/5 for 9780306406157",
page.getHtmlElementById("review:saved").getTextContent());
}
}
@Test
void aShortIsbnShowsTheMessage() throws Exception {
try (WebClient client = new WebClient(BrowserVersion.CHROME)) {
HtmlPage page = client.getPage(BookshelfServer.url() + "/review.xhtml");
page.<HtmlTextInput>getHtmlElementById("review:isbn").type("12345");
page.<HtmlTextInput>getHtmlElementById("review:rating").type("4");
page.<HtmlSubmitInput>getHtmlElementById("review:submit").click();
client.waitForBackgroundJavaScript(5_000);
assertTrue(page.getHtmlElementById("review:messages").getTextContent()
.contains("ISBN must have 13 digits"));
}
}
}
Both tests passed with Mojarra 4.1.17 and failed on MyFaces 4.1.4. On MyFaces the page’s very first script fails to load with
ScriptException: identifier is a reserved word: class, raised while evaluating jakarta.faces.resource/faces.js.xhtml: the faces.js of MyFaces 4.1.4 uses the class syntax, which HtmlUnit 5.5.0’s engine cannot parse.
The conclusion is not that HtmlUnit is unusable: it is a quick way to test forms and links on pages without much script, and the Cookbook-era HtmlUnit-style test is still a valid idea. It is that its result depends on the JavaScript in the page, so a suite that must run on
both implementations (or on PrimeFaces components) belongs in a real browser.
Source: HtmlUnit
A Regression Test for Escaping
Security is correctness, and a browser test is the only place where "the input stays text" can be proved end to end. The test submits a value that would execute if a component wrote it unescaped, into the standard message of an
h:messages and into the message of a PrimeFaces growl, and asserts three things: the text appears literally, no element was created from it, and the page title (which the payload would change) is intact.
package org.example.bookshelf.e2e;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
/**
* Hostile input must come back as text. The validator message repeats what the user typed, which is exactly
* where a component that does not escape its messages would turn the input into markup.
*/
class HostileInputIT extends BrowserTest {
private static final String HOSTILE = "<img src=x onerror=\"document.title='pwned'\">";
private static final By INJECTED = By.cssSelector("img[src='x']");
@BeforeEach
void cleanPage() {
driver.manage().deleteAllCookies();
}
@Test
void standardMessagesEscapeTheInput() {
open("/review.xhtml");
driver.findElement(By.id("review:isbn")).sendKeys(HOSTILE);
driver.findElement(By.id("review:rating")).sendKeys("4");
driver.findElement(By.cssSelector("[data-testid='save-review']")).click();
FacesAjax.waitIdle(driver);
assertTrue(driver.findElement(By.id("review:messages")).getText().contains("<img src=x onerror="));
assertEquals(0, driver.findElements(INJECTED).size()); // no element was created from the text
assertEquals("Bookshelf", driver.getTitle()); // and no handler ran
}
@Test
void growlEscapesTheInput() {
driver.get(base + "/shelf.xhtml");
driver.findElement(By.id("shelf:open")).click();
new WebDriverWait(driver, Duration.ofSeconds(5)).until(ExpectedConditions.elementToBeClickable(By.id("shelf:isbn")));
driver.findElement(By.id("shelf:isbn")).sendKeys(HOSTILE);
driver.findElement(By.id("shelf:save")).click();
new WebDriverWait(driver, Duration.ofSeconds(5)).until(d -> !d.findElements(By.cssSelector(".ui-growl-message")).isEmpty()
&& !d.findElement(By.cssSelector(".ui-growl-message")).getText().isBlank());
assertTrue(driver.findElement(By.cssSelector(".ui-growl-message")).getText().contains("<img src=x onerror="));
assertEquals(0, driver.findElements(INJECTED).size());
assertEquals("Bookshelf", driver.getTitle());
}
}
Both tests passed on both implementations. One of them failed intermittently on MyFaces until the wait for the growl was changed from "the element exists" to "the element has text": the container is created before the text is filled in, so a test that reads too early sees an
empty string and fails for a reason that has nothing to do with escaping. The rule against escape="false" on user content is on the security page; this is its regression test.
Source: OWASP Top 10 and Jakarta Faces 4.1 VDL: h:messages
Choosing a Browser Tool
| Tool | Strength | Cost on a Faces page | Use it for |
|---|---|---|---|
Selenium WebDriver |
The standard, every browser, a huge ecosystem, Selenium Manager removes the driver step. |
You write the Ajax wait (Waiting for Ajax) and the retry logic. |
Suites that already exist, grids and cloud browsers. |
Playwright for Java |
Auto-wait, retrying assertions, traces, test-id locators. |
Chromium, Firefox and WebKit, according to the Playwright documentation (not re-checked for every release), and a bundled driver per release. |
New suites on plain Faces pages. |
PrimeFaces Selenium |
Typed PrimeFaces components, guards that know the PrimeFaces client, screenshot on failure. |
Tied to PrimeFaces and to its JUnit 5 extensions. |
PrimeFaces pages: tables, dialogs, menus, uploads. |
Graphene (Arquillian) |
One tool for deployment and browser; page fragments. |
Arquillian setup; Selenium version lags (4.43.0 in 3.0.1.Final). |
Suites already on Arquillian. |
HtmlUnit |
No browser binary, fast. |
Its JavaScript engine failed on MyFaces' |
Pages with little script, quick checks of links and forms. |
The end-to-end testing page compares Playwright, Cypress and Selenium for any web stack (that comparison is the subject of issue #258 of this documentation project, named here in prose only); nothing in it changes for Faces except the Ajax wait and the client ids shown above.
Testing Ajax and the View State Without a Browser
A browser is the wrong tool for some questions: "does the server answer a validation failure with HTTP 200 and the message?", "what happens when the view has expired?", "does a view survive the loss of the session when the state is on the client?".
Those are questions about the protocol, and the protocol is plain HTTP: a GET that returns the page with a jakarta.faces.ViewState hidden field, and a POST with that field, the form’s values and a few jakarta.faces.* parameters that
faces.js adds. A test can send them with the JDK’s HttpClient in milliseconds and parse the XML that comes back (the partial response, as the Ajax page describes it).
package org.example.bookshelf.e2e;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathFactory;
import org.w3c.dom.Document;
/** Speaks the Faces Ajax protocol over plain HTTP, the way faces.js does, without a browser. */
final class FacesHttp {
private static final Pattern VIEW_STATE =
Pattern.compile("<input[^>]*name=\"jakarta.faces.ViewState\"[^>]*value=\"([^\"]*)\"");
private FacesHttp() {
}
static String get(HttpClient client, String page) throws Exception {
return client.send(HttpRequest.newBuilder(URI.create(page)).build(),
HttpResponse.BodyHandlers.ofString()).body();
}
static String viewStateOf(String html) {
Matcher matcher = VIEW_STATE.matcher(html);
assertTrue(matcher.find(), "no jakarta.faces.ViewState field in the page");
return matcher.group(1);
}
/** The parameters that f:ajax sends for the Save button of review.xhtml, as Mojarra and MyFaces send them. */
static HttpResponse<String> save(HttpClient client, String page, String viewState, String isbn, String rating)
throws Exception {
Map<String, String> form = new LinkedHashMap<>();
form.put("review", "review");
form.put("review_SUBMIT", "1"); // MyFaces looks for this marker, Mojarra ignores it
form.put("review:isbn", isbn);
form.put("review:rating", rating);
form.put("review:comment", "");
form.put("jakarta.faces.ViewState", viewState);
form.put("jakarta.faces.source", "review:submit");
form.put("jakarta.faces.partial.event", "click");
form.put("jakarta.faces.partial.execute", "review:submit review");
form.put("jakarta.faces.partial.render", "review");
form.put("jakarta.faces.behavior.event", "action");
form.put("jakarta.faces.partial.ajax", "true");
String body = form.entrySet().stream()
.map(e -> URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8) + "="
+ URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"));
HttpRequest request = HttpRequest.newBuilder(URI.create(page))
.header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
.header("Faces-Request", "partial/ajax")
.header("X-Requested-With", "XMLHttpRequest")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
return client.send(request, HttpResponse.BodyHandlers.ofString());
}
static Document xml(String text) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
return factory.newDocumentBuilder().parse(new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8)));
}
static String text(Document document, String xpath) throws Exception {
return (String) XPathFactory.newInstance().newXPath().evaluate(xpath, document, XPathConstants.STRING);
}
}
package org.example.bookshelf.e2e;
import static org.example.bookshelf.e2e.FacesHttp.get;
import static org.example.bookshelf.e2e.FacesHttp.save;
import static org.example.bookshelf.e2e.FacesHttp.text;
import static org.example.bookshelf.e2e.FacesHttp.viewStateOf;
import static org.example.bookshelf.e2e.FacesHttp.xml;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.net.CookieManager;
import java.net.http.HttpClient;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.w3c.dom.Document;
/** Tests the Ajax protocol itself: no browser, no JavaScript, one HTTP client that keeps the session cookie. */
class AjaxProtocolIT {
private final String page = BookshelfServer.url() + "/review.xhtml";
private HttpClient client;
@BeforeEach
void newBrowser() {
client = HttpClient.newBuilder().cookieHandler(new CookieManager()).build();
}
@Test
void aValidSubmissionReturnsAPartialResponseThatUpdatesTheForm() throws Exception {
HttpResponse<String> response = save(client, page, viewStateOf(get(client, page)), "9780306406157", "5");
assertEquals(200, response.statusCode());
assertTrue(response.headers().firstValue("Content-Type").orElse("").startsWith("text/xml"));
Document document = xml(response.body());
assertEquals("partial-response", document.getDocumentElement().getTagName());
assertTrue(text(document, "/partial-response/changes/update[@id='review']")
.contains("Saved review 5/5 for 9780306406157"));
}
@Test
void anInvalidSubmissionStillReturnsHttp200WithTheMessage() throws Exception {
HttpResponse<String> response = save(client, page, viewStateOf(get(client, page)), "12345", "5");
assertEquals(200, response.statusCode());
// The update is HTML in a CDATA section; MyFaces writes the quotes as ", Mojarra writes them as they are
String html = text(xml(response.body()), "/partial-response/changes/update[@id='review']")
.replace(""", "\"");
assertTrue(html.contains("ISBN must have 13 digits, but \"12345\" has 5."));
}
@Test
void theResponseCarriesTheViewStateForTheNextRequest() throws Exception {
String body = save(client, page, viewStateOf(get(client, page)), "9780306406157", "5").body();
String viewState = text(xml(body), "/partial-response/changes/update[contains(@id,'ViewState')]");
assertFalse(viewState.isBlank());
}
@Test
void anExpiredViewIsReportedInsideTheXml() throws Exception {
String viewState = viewStateOf(get(client, page));
newBrowser(); // a new client: the session cookie is gone
HttpResponse<String> response = save(client, page, viewState, "9780306406157", "5");
assertEquals(200, response.statusCode()); // the error is inside the XML, not in the status
assertTrue(text(xml(response.body()), "/partial-response/error/error-name").contains("ViewExpiredException"));
}
}
All four tests passed on Mojarra 4.1.17 and on MyFaces 4.1.4. What they establish, and what they showed:
-
A validation failure is an HTTP 200. The response is a
text/xmldocument whose root ispartial-response, with the failed message already rendered inside the<update id="review">element. A test or a monitor that looks for a 4xx to detect a failed form will never see one. -
The new state travels in the response. An
<update>element whose id containsjakarta.faces.ViewStatecarries the value for the next request. Its id differs:j_id1:jakarta.faces.ViewState:0on Mojarra andj_id__v_0:jakarta.faces.ViewState:1on MyFaces, which is why the test matches oncontains(@id,'ViewState'). -
An expired view is an error inside the XML, not a status. A second client with no session cookie sends the state of the first; the response is still HTTP 200, with
<error><error-name>naming theViewExpiredException. The browser-side handling of that element is on the Ajax page, and the state saving page explains why the state is bound to the session. -
Raw tests are tied to the implementation. The hand-built request has a marker field named after the form,
review_SUBMIT=1, which MyFaces looks for in a postback (Mojarra ignores it). MyFaces writes the quotes of the message as"inside the HTML in the response, Mojarra writes them as they are, and the two markups differ in other details (the Mojarra form holds a hidden field named after the form,onclick="mojarra.ab(...)"againstmyfaces.ab). The listing is written to accept both, and for this reason a handful of protocol tests is plenty: they are for questions the browser cannot answer, not a replacement for it.
Source: Jakarta Faces 4.1 JavaScript API: faces.ajax and Jakarta Faces 4.1 specification, 2.2 Standard Request Processing Lifecycle Phases
Client State Saving in a Test
With jakarta.faces.STATE_SAVING_METHOD set to client the view state is in the page itself, not in the session. A test starts a second application with that parameter (the helper BookshelfServer.start takes the context parameters, which also
makes it the tool for any "same WAR, different setting" test) and checks the consequence that matters in a cluster: a view still works when the session is gone.
package org.example.bookshelf.e2e;
import static org.example.bookshelf.e2e.FacesHttp.get;
import static org.example.bookshelf.e2e.FacesHttp.save;
import static org.example.bookshelf.e2e.FacesHttp.text;
import static org.example.bookshelf.e2e.FacesHttp.viewStateOf;
import static org.example.bookshelf.e2e.FacesHttp.xml;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.net.CookieManager;
import java.net.http.HttpClient;
import java.net.http.HttpResponse;
import java.util.Map;
import org.junit.jupiter.api.Test;
/** The same form with client-side state saving: the view state travels with the page, not in the session. */
class ClientStateIT {
@Test
void aViewSurvivesTheLossOfTheSessionWhenTheStateIsOnTheClient() throws Exception {
try (var server = BookshelfServer.start(Integer.getInteger("stage.port", 8757),
Map.of("jakarta.faces.STATE_SAVING_METHOD", "client"))) {
String page = server.url() + "/review.xhtml";
HttpClient first = HttpClient.newBuilder().cookieHandler(new CookieManager()).build();
String viewState = viewStateOf(get(first, page));
assertTrue(viewState.length() > 100, "client state is the serialised view, not a short id");
HttpClient second = HttpClient.newBuilder().cookieHandler(new CookieManager()).build(); // no session
HttpResponse<String> response = save(second, page, viewState, "9780306406157", "5");
assertEquals(200, response.statusCode());
assertEquals("", text(xml(response.body()), "/partial-response/error/error-name"));
assertTrue(text(xml(response.body()), "/partial-response/changes/update[@id='review']")
.contains("Saved review 5/5"));
}
}
}
The test passed on both implementations. The state value was longer than 100 characters (it is the serialised, compressed, encrypted and authenticated view), and a client without a session cookie could post it and get the saved review back. The client state page measured the sizes (1088 characters on Mojarra and 704 on MyFaces for the same view) and explains the keys; a deployment that must survive a restart or run on several nodes has to fix them, a test that only starts a server cannot show that, and the keys section has the settings.
The UnitTest and SystemTest Project Stages
jakarta.faces.application.ProjectStage has four constants: Development, UnitTest, SystemTest and Production. The specification defines the property (section 7.1.9): the value comes from the context parameter jakarta.faces.PROJECT_STAGE or from JNDI,
and Application.getProjectStage() returns it. It does not say what the two test stages change, and the implementations barely do. The configuration page measured the effects: on MyFaces UnitTest and SystemTest behave like Production in
everything checked, and on Mojarra they differ from Production only in the text of an unresolved h:link. The stages are therefore a convention for the application’s own code, and testing is where that convention is useful:
-
UnitTestmarks code that runs under a test harness, so that it can skip what needs a real container (a connection to a mail server, a timer). -
SystemTestmarks a deployed application that is driven from outside, so that it can offer what only a test needs: a way to put the data back to a known state, a clock that can be set, a fake payment gateway.
Anything a stage unlocks is a back door, and the rest of this section is about keeping it closed in production. The Bookshelf version is a button that resets the catalogue, available in SystemTest only.
package org.example.bookshelf.web;
import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.application.ProjectStage;
import jakarta.faces.context.FacesContext;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import org.example.bookshelf.service.BookRepository;
/** Test support that exists only while the application runs in the SystemTest project stage. */
@Named
@RequestScoped
public class FixtureBean {
@Inject
private FacesContext facesContext;
@Inject
private BookRepository repository;
public boolean isAvailable() {
return facesContext.getApplication().getProjectStage() == ProjectStage.SystemTest;
}
public void reset() {
// rendered="#{fixtureBean.available}" hides the button, but the action must refuse on its own as well
if (!isAvailable()) {
throw new IllegalStateException("Fixtures are available in the SystemTest stage only");
}
repository.reset();
}
}
<ui:composition xmlns="http://www.w3.org/1999/xhtml"
xmlns:h="jakarta.faces.html"
xmlns:ui="jakarta.faces.facelets"
template="/WEB-INF/templates/layout.xhtml">
<ui:define name="content">
<h1>Test fixtures</h1>
<h:form id="fixture">
<h:commandButton id="reset" value="Reset catalogue" action="#{fixtureBean.reset}"
rendered="#{fixtureBean.available}"/>
</h:form>
</ui:define>
</ui:composition>
package org.example.bookshelf.web;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import jakarta.faces.application.Application;
import jakarta.faces.application.ProjectStage;
import jakarta.faces.context.FacesContext;
import org.example.bookshelf.service.BookRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class FixtureBeanTest {
@Mock
FacesContext facesContext;
@Mock
Application application;
@Mock
BookRepository repository;
@InjectMocks
FixtureBean bean;
@Test
void resetWorksInTheSystemTestStage() {
when(facesContext.getApplication()).thenReturn(application);
when(application.getProjectStage()).thenReturn(ProjectStage.SystemTest);
assertTrue(bean.isAvailable());
bean.reset();
verify(repository).reset();
}
@Test
void resetRefusesInProductionEvenIfTheButtonWasForged() {
when(facesContext.getApplication()).thenReturn(application);
when(application.getProjectStage()).thenReturn(ProjectStage.Production);
assertFalse(bean.isAvailable());
assertThrows(IllegalStateException.class, bean::reset);
verify(repository, never()).reset();
}
}
package org.example.bookshelf.e2e;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.net.CookieManager;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import org.junit.jupiter.api.Test;
/** The same WAR started twice with a different jakarta.faces.PROJECT_STAGE. */
class ProjectStageIT {
private static String fixturePage(String baseUrl) throws Exception {
return HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create(baseUrl + "/fixture.xhtml")).build(),
HttpResponse.BodyHandlers.ofString()).body();
}
@Test
void theResetButtonExistsOnlyInTheSystemTestStage() throws Exception {
try (var systemTest = BookshelfServer.start(Integer.getInteger("stage.port", 8757),
Map.of("jakarta.faces.PROJECT_STAGE", "SystemTest"))) {
String body = fixturePage(systemTest.url());
assertTrue(body.contains("Reset catalogue"), body);
}
try (var production = BookshelfServer.start(Integer.getInteger("stage.port", 8757),
Map.of("jakarta.faces.PROJECT_STAGE", "Production"))) {
assertFalse(fixturePage(production.url()).contains("Reset catalogue"));
}
}
@Test
void aForgedPostCannotRunTheResetInProduction() throws Exception {
try (var production = BookshelfServer.start(Integer.getInteger("stage.port", 8757),
Map.of("jakarta.faces.PROJECT_STAGE", "Production"))) {
HttpClient client = HttpClient.newBuilder().cookieHandler(new CookieManager()).build();
String page = production.url() + "/fixture.xhtml";
String viewState = FacesHttp.viewStateOf(FacesHttp.get(client, page));
// the button is not in the page, but a client can send its parameters anyway
String body = "fixture=fixture&fixture_SUBMIT=1&fixture%3Areset=Reset+catalogue&jakarta.faces.ViewState="
+ URLEncoder.encode(viewState, StandardCharsets.UTF_8);
HttpResponse<String> response = client.send(HttpRequest.newBuilder(URI.create(page))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode()); // 500 would mean the action ran and refused
assertFalse(response.body().contains("Reset catalogue"));
}
}
}
All of these passed on both implementations (the two unit tests, and the two server tests). In SystemTest the page contains the button and in Production it does not. A request that carries the button’s parameters anyway (fixture:reset=Reset catalogue) was
ignored with HTTP 200: a component that is not rendered is not decoded, so its action is not called. The check inside reset() is the second lock, for the day a page, a f:viewAction or a refactoring exposes the method by another route. The stage comes from the deployment
(a context parameter or the JNDI entry, which wins when both are set), never from a request parameter or a cookie, and a production deployment must not set SystemTest for convenience.
Load Testing
A load test replays what FacesHttp does, at volume, and everything that makes a Faces page different from a REST endpoint is in those few lines: a session cookie per virtual user, a GET first to obtain the jakarta.faces.ViewState value (and, on MyFaces, the form marker),
and a POST that sends it back with the form’s client ids as parameter names. Use one cookie jar and one extracted state per virtual user; replaying a recorded state shared by all users fails with a ViewExpiredException, as the protocol test showed. In Apache JMeter that is an HTTP Cookie Manager and a Regular Expression
Extractor on the GET response, whose output variable is used in the POST. The JMeter page of the backend section covers the tool, and the performance page says what to measure (state size, session memory, render time). The JMeter elements were not run for this page; the
protocol that they must reproduce was.
Running the Tests in CI
-
Two phases. Surefire runs
*Testinmvn test; Failsafe runs*ITinmvn verifyand only fails the build after the server has been stopped. Browser and server tests are in the second. -
Both implementations. The production runtime decides which Faces implementation matters, and an application that is expected to run on more than one should run the browser suite with both (
mvn verifyandmvn verify -Pmyfaceshere);mvn cleanbetween them. -
Fixed, configurable ports. The ports in the listings are system properties with defaults, so parallel builds on a shared agent do not collide.
-
A headless browser and a driver cache. Headless Chrome plus Selenium Manager’s cache (
SE_CACHE_PATH) keeps builds fast and offline-capable once the driver is downloaded. Playwright is told not to download its own browser. -
Artifacts of a failure. Keep Playwright’s
target/tracesand PrimeFaces Selenium’sscreenshotDirectoryas CI artifacts. -
No real secrets. A test application that needs a login uses a test identity store and placeholder credentials supplied by the environment, not values in the repository.
Mojarra and MyFaces Compared
| Aspect | Mojarra 4.1.17 | MyFaces 4.1.4 |
|---|---|---|
Unit tests with Mockito |
Identical: the tests call the application’s classes, not the runtime. |
Identical. |
Raw postback |
No form marker needed. |
Needs |
Message markup |
Quotes written as they are. |
Quotes written as |
Partial response |
|
|
HtmlUnit 5.5.0 |
The page’s script runs; Ajax tests pass. |
|
|
Differ from |
Same as |
Selenium, Playwright, PrimeFaces Selenium |
All tests pass. |
All tests pass. |
Source: Eclipse Mojarra and Apache MyFaces Core 4.1
What Changed per Version
| Version | Change |
|---|---|
JSF 2.0 |
|
2.3 |
The |
3.0 |
The package rename to |
4.0 |
Facelets-only (JSP removed); the namespaces |
4.1 |
Expression Language 6.0 with |
Tools |
Arquillian 1.10.2.Final (JUnit 5 extension |
5.0 (upcoming) |
A milestone release; this page does not describe it. The ideas above (inject the context, test the protocol cheaply, drive the real page rarely) do not depend on it. |
Where the Books Are Outdated
-
JSFUnit. Leonard’s JSF 2.0 cookbook devotes a chapter to JSFUnit, a JBoss framework that runs tests inside the container with access to the managed beans, the
FacesContextand the component tree, based on JUnit and HtmlUnit. The book works with JSFUnit 1.1.0 GA, JSF 1.1, 1.2 and the JSF 2.0 beta, HtmlUnit 2.5, an Ant task and a deployer for JBoss AS 5. The project page still online (jsfunit.jboss.org) shows as its newest announcement the 2.0.0.Beta2 of 15 September 2011, and I found noorg.jboss.jsfunitartifact on Maven Central. It targets thejavax.facespackages, so it cannot run on a Jakarta Faces 3.0 or later application at all. The need it answered has three current answers: Arquillian for tests inside the container (Integration Tests with Arquillian), the HTTP-level tests of Testing Ajax and the View State Without a Browser for the protocol, and a browser tool for the page (Browser Tests). -
The container as the only place a bean can be tested. The older books test through the container because their beans reached for
FacesContext.getCurrentInstance(). With injection (2.3) and constructor injection of services the same classes are tested in a plain JVM (Unit-Testing Backing Beans); the rest of the books' advice about the container is still right for the wiring, and Arquillian is where it is done now. -
Faces Console. The same chapter introduces Faces Console 1.7, a stand-alone tool for browsing and checking the Faces 1.1 configuration and TLD files. The 4.1
faces-config.xmlis validated against its schema by any current IDE or build, and the tag documentation is the VDL. -
JMeter and the view state. The cookbook’s load-testing recipe extracts the view state from each response with a regular expression and sends it back. The idea has not changed (Load Testing), but the field is
jakarta.faces.ViewState(notjavax.faces.ViewState), a stateless view sends the literalstateless, and the Ajax requests need theFaces-Request: partial/ajaxheader and thejakarta.faces.partial.*parameters. -
Still valid. Mann’s JSF in Action advises putting the core logic of a converter or validator in a method that uses no runtime Faces classes, so that its JUnit tests need no web container or mock objects, and its business objects are unit tested the same way. The advice stands; the only change is that a converter that ignores its context arguments can be called with
null(Unit-Testing Converters and Validators) and that an injectedFacesContextcan be mocked. -
Silent. In the extracted text of the eight books, Selenium, Arquillian, Mockito and HtmlUnit appear only in Leonard’s cookbook (JSFUnit and HtmlUnit). None of them covers testing the Ajax protocol over HTTP, one suite against both implementations, mocking the injected
FacesContext, test ids through passthrough attributes, browser tests of PrimeFaces components, or theSystemTeststage as a switch for fixtures.
Common Mistakes
-
Sleeping instead of waiting.
Thread.sleepafter a click is the most frequent cause of slow and flaky suites. Wait for the Faces event, for the retrying assertion or for the library’s guard (Waiting for Ajax). -
Selecting by generated id.
j_id_xyids change when a component is added before it. Give the component anid, and usept:data-testidor a label as the hook. -
Testing everything in the browser. A converter rule tested through a page costs a hundred times more than the same rule in
IsbnConverterTest. Put the rule in the unit, and one end-to-end test per feature in the browser. -
Mocking what you do not own, too deeply. A chain such as
facesContext.getExternalContext().getRequestMap()needs three mocks; inject the narrow object (theExternalContext, a@RequestMap Map) instead, and the test stays short. -
Static state between tests. A
@ApplicationScopedin-memory repository keeps changes from one test to the next. The test project resets it through theSystemTestfixture; a unit test constructs a fresh object. -
A shared Tomcat directory after a profile change. A leftover
target/webappmixed Mojarra and MyFaces and Weld refused to start (see A Server for the Tests). -
Forgetting the second implementation. A test suite green on Mojarra does not prove the page on MyFaces (HtmlUnit above is one example, the markup differences another).
-
A back door left open. A fixture, a debug page or a reset endpoint guarded by a stage or a flag needs a test in the
Productionconfiguration that proves it is not there (ProjectStageIT). -
Asserting on exact markup. Quotes, ids and attribute order differ between implementations and versions; assert on text, on the message, or on the element’s presence.
Related Pages
-
Backing beans and CDI: the layering that makes the unit tests below Faces possible.
-
Request processing lifecycle: which phase calls which code under test.
-
Configuration and project stages: the measured effect of every stage.
-
State saving and stateless views and Ajax: the protocol that Testing Ajax and the View State Without a Browser tests.
-
PrimeFaces Ajax, forms and validation: the components that PrimeFaces Selenium wraps.
-
Security: the escaping and CSRF rules that A Regression Test for Escaping turns into regression tests.
-
End-to-end testing with real browsers, JMeter and Spring Boot unit and integration testing for the tools in general.
-
Jakarta EE Servers, Tomcat and Weld, Quarkus and Deployment and Clustering: the container and packaging that the tests of this page run against.
-
Spring Boot with JoinFaces: tests of a Faces application hosted by Spring Boot.
-
Persistence and Services: testing the service layer behind the beans.
-
Migrating to Jakarta Faces: the smoke test that a migration leaves behind.
-
Best Practices: the testing rules of the checklist.
-
PrimeFaces, OmniFaces and other component libraries: the libraries whose components the browser tests exercise.
References
-
Jakarta Faces 4.1 specification, 2.2 Standard Request Processing Lifecycle Phases
-
Jakarta Faces 4.1 specification, 5.6.1 Jakarta Faces Objects Valid for @Inject Injection
-
Jakarta Faces 4.1 specification, 7.1.9 ProjectStage Property
-
Jakarta Faces 4.1 specification, B.4 Changes between 2.2 and 2.3
-
Jakarta Faces 4.1 specification, B.2 Changes between 4.0 and 3.0
-
Jakarta EE Tutorial: Introduction to Contexts and Dependency Injection
-
Arquillian guide: Functional Testing using Drone and Graphene