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.

flowchart TD subgraph JVM["Plain JVM, no Faces runtime"] A["Unit tests: services, beans, converters, validators, constraints"] end subgraph MOCK["Mocked Faces objects"] B["Mockito mocks of FacesContext and Application, MyFaces Test mock objects"] end subgraph SERVER["Real container, no browser"] C["Arquillian in-container and client mode"] D["HTTP tests of the Ajax protocol"] E["HtmlUnit"] end subgraph BROWSER["Real browser"] F["Selenium, Playwright, PrimeFaces Selenium, Graphene"] end A --> B --> SERVER --> BROWSER
Table 1. What each height runs
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 (getAsObject, validate, search).

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 validate and updateModel, called by hand on real component objects.

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, faces.js, and the component library’s JavaScript.

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).

shelf/pom.xml (excerpt) — APIs provided, one implementation per profile, Mockito registered as an agent
        <!-- 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>

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). The maven-dependency-plugin properties goal 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=US arguments 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.

CatalogueBean.java — the bean under test (view scoped, injected collaborators)
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;
    }
}
CatalogueBeanTest.java — JUnit 5 and Mockito; no container, no Faces runtime
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:

  • @InjectMocks writes the two mocks into the private @Inject fields; 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 @PostConstruct method. The method is package-private on purpose; the test sits in the same package.

  • ArgumentCaptor reads the FacesMessage that 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 form catalogue).

  • 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 @ViewScoped bean must be Serializable because 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.

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.

Messages.java — a static helper of the kind older code bases have
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));
    }
}
MessagesTest.java — mockStatic replaces the static method for the duration of the try block, on this thread only
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.

BookServiceTest.java — constructor injection, a mocked Event, no container
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().

IsbnConverter.java
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();
    }
}
IsbnConverterTest.java
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.

IsbnLengthValidator.java
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));
    }
}
IsbnLengthValidatorTest.java
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.

ReviewBeanConstraintsTest.java
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.

myfaces-test/pom.xml (excerpt) — the framework, with the pieces a servlet container normally provides
        <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>
IsbnInputTest.java — real UIInput components, the real validation step, mock objects around them
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 UIInput logs a warning "No Renderer found for component" and the component falls back to its own converter. The tests do not read rendered output, only isValid(), the local value and the messages.

  • validatorsRunOnTheConvertedValue records the order the lifecycle uses: a converter runs first and the validators receive the converter’s result. A validator written for String that is attached to an input with the Isbn converter fails with a ClassCastException. 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.

  • updateModelValuesWritesTheBean shows the phases split: after validate the bean is still untouched, and only updateModel writes 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.

The dependencies of the arquillian module (excerpt) — BOMs, the JUnit 5 runner, the embedded TomEE adapter
    <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>
arquillian.xml — one container, fixed ports so that builds do not collide
<?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.

BookshelfDeployments.java — the archive of every test: the classes, the bundle, the configuration and pages written for the test
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");
    }
}
BookServiceIT.java — in-container mode: the test class is deployed and CDI injects into it
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"));
    }
}
CataloguePageIT.java — client mode: the archive is deployed as it is and the test is an HTTP client
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.

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

org.apache.tomee:arquillian-tomee-embedded:10.2.0

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)

org.wildfly.arquillian:wildfly-arquillian-container-managed and wildfly-arquillian-container-remote, 5.1.0.Final (6.0.0.Alpha1 also published)

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

org.jboss.arquillian.container:arquillian-weld-embedded 4.0.1.Final

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.0 with provided scope put jakarta.faces-api 4.0.1 in front of MyFaces' own API jar, and the deployment failed with NoClassDefFoundError: com/sun/faces/util/Util raised from jakarta.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 first SEVERE line of the log, not the last stack trace.

  • Records in Expression Language. #{book.title} on the Book record threw PropertyNotFoundException: Property [title] not found on type [org.example.bookshelf.model.Book] on this Jakarta EE 10 server. Expression Language 6.0 (Jakarta EE 11) adds RecordELResolver, 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: null and 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:

The webdriver extension of arquillian.xml
    <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.

CatalogueGrapheneIT.java — Drone injects the browser, Graphene injects the elements and waits for the Ajax request
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.

BookshelfServer.java — the embedded Tomcat, started once per JVM
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" inside h:form id="catalogue" is rendered with the id catalogue: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’s getByLabel("ISBN") works and doubles as an accessibility check.

  • Passthrough attributes make stable hooks. pt:data-testid="save-review" (namespace jakarta.faces.passthrough) adds a data-testid attribute 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).

index.xhtml — the catalogue page under test (search, Ajax, messages, a table)
<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>
review.xhtml — the review form under test (required, a custom validator, a range validator, Ajax)
<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>

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.

sequenceDiagram participant T as Test participant B as Browser page participant S as Server T->>B: click Save B->>S: POST with jakarta.faces.partial.ajax=true Note over B: onEvent begin, pending is 1 S-->>B: partial-response XML Note over B: onEvent success, pending is 0 T->>B: waitIdle reads window.__ajax.pending T->>B: assert on the DOM
FacesAjax.java — counts the Ajax events that faces.js reports
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.

PinnedChrome.java and BrowserTest.java — a headless Chrome per test class
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);
    }
}
CatalogueSeleniumIT.java — search, borrow and stay on the same page
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);
    }
}
ReviewSeleniumIT.java — validation messages, the saved review and the view state field
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.

ReviewPlaywrightIT.java — locators by label and test id, retrying assertions, a trace per test, the Ajax request inspected
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.

config.properties — an adapter that starts the server, an adapter that creates the browser
deployment.adapter=org.example.bookshelf.e2e.BookshelfDeployment
webdriver.adapter=org.example.bookshelf.e2e.PinnedChromeAdapter
webdriver.browser=chrome
webdriver.headless=true
timeout.ajax=10
Table 2. The configuration keys (from the README and ConfigProvider in the 16.0.0 sources)
Key Default Meaning

deployment.baseUrl

none

The URL of a running application (remote testing). Either this or deployment.adapter is required.

deployment.adapter

none

A class implementing DeploymentAdapter that starts and stops the application.

webdriver.adapter

built-in

A class implementing WebDriverAdapter. The built-in adapter uses WebDriverManager to download a driver for chrome, firefox or safari.

webdriver.browser, webdriver.headless, webdriver.version, webdriver.logLevel

none, false, newest, WARNING

The browser, headless mode, the driver version and the level of browser console messages that are collected.

timeout.gui, timeout.ajax, timeout.http, timeout.documentLoad, timeout.fileUpload

2, 10, 10, 15, 20 (seconds)

Waits of the GUI, Ajax and HTTP guards, document load and file upload.

disableAnimations

true

Turns the component library’s animations off during the test, a large source of flakiness.

screenshotDirectory

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.

BookshelfDeployment.java and PinnedChromeAdapter.java — the two adapters the configuration names
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.

shelf.xhtml — a PrimeFaces data table with a paginator, a growl, a modal dialog and a menu
<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>
ShelfPage.java — the page object: one typed field per component, located by client id
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";
    }
}
ShelfPrimeIT.java — extends AbstractPrimePageTest; the page object is a test method parameter
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) and page.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, so clickUnguarded() is the call for it, and PrimeSelenium.waitGui() waits for the dialog to be visible.

  • PrimeSelenium.guardAjax(element).click() guards any plain WebElement, as the borrow test does for a button inside a table row.

  • p:growl writes 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-message under 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.

ReviewHtmlUnitIT.java — no browser binary; waitForBackgroundJavaScript lets the Ajax request finish
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.

HostileInputIT.java — the validator message repeats what the user typed, which is where unescaped output would show
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.

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' faces.js.

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).

FacesHttp.java — the requests and the parsing of the response, written once
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);
    }
}
AjaxProtocolIT.java — four questions about the Ajax protocol
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 &quot;, Mojarra writes them as they are
        String html = text(xml(response.body()), "/partial-response/changes/update[@id='review']")
                .replace("&quot;", "\"");
        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/xml document whose root is partial-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 contains jakarta.faces.ViewState carries the value for the next request. Its id differs: j_id1:jakarta.faces.ViewState:0 on Mojarra and j_id__v_0:jakarta.faces.ViewState:1 on MyFaces, which is why the test matches on contains(@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 the ViewExpiredException. 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 &quot; 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(...)" against myfaces.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.

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.

ClientStateIT.java — the same form, state on the client, no session on the second request
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:

  • UnitTest marks 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).

  • SystemTest marks 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.

FixtureBean.java — the check is in the action as well as in the view
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();
    }
}
fixture.xhtml — rendered only when the stage allows it
<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>
FixtureBeanTest.java — the stage is a mocked Application, so no deployment is needed
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();
    }
}
ProjectStageIT.java — the same WAR started with two stages, and a forged post
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 *Test in mvn test; Failsafe runs *IT in mvn verify and 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 verify and mvn verify -Pmyfaces here); mvn clean between 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/traces and PrimeFaces Selenium’s screenshotDirectory as 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 <formId>_SUBMIT=1.

Message markup

Quotes written as they are.

Quotes written as &quot; in the HTML of the response.

Partial response

<partial-response> without an id; ViewState update j_id1:jakarta.faces.ViewState:0.

partial-response id="j_id__v_0"; ViewState update j_id__v_0:jakarta.faces.ViewState:1.

HtmlUnit 5.5.0

The page’s script runs; Ajax tests pass.

faces.js is rejected with "identifier is a reserved word: class".

UnitTest and SystemTest stages

Differ from Production in the disabled-link text only.

Same as Production.

Selenium, Playwright, PrimeFaces Selenium

All tests pass.

All tests pass.

What Changed per Version

Version Change

JSF 2.0

ProjectStage with the four constants is introduced (API "Since 2.0"). JSFUnit, the JBoss test framework of that era, reached a 2.0 beta only in 2011 and has no later release.

2.3

The FacesContext, ExternalContext, Flash, ResourceHandler and HttpSession become injectable with @Inject, which is what makes beans mockable without the static accessor (section 5.6.1, B.4).

3.0

The package rename to jakarta.faces.*: every import, namespace and test dependency above moves with it. Tests written for javax.faces need the same rename as the code.

4.0

Facelets-only (JSP removed); the namespaces jakarta.faces.html and the others of the page listings replace http://xmlns.jcp.org/jsf/*. Expression Language 5.0.

4.1

Expression Language 6.0 with RecordELResolver (Jakarta EE 11): a record can be used as a bean in a page. Servlet 6.1 and CDI 4.1.

Tools

Arquillian 1.10.2.Final (JUnit 5 extension arquillian-junit5-container), Drone and Graphene 3.0.1.Final (Selenium 4), Selenium 4.50.0 with Selenium Manager, Playwright 1.63.0, PrimeFaces Selenium 16.0.0 (JUnit 5), Mockito 5 (inline mock maker as the default).

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 FacesContext and 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 no org.jboss.jsfunit artifact on Maven Central. It targets the javax.faces packages, 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.xml is 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 (not javax.faces.ViewState), a stateless view sends the literal stateless, and the Ajax requests need the Faces-Request: partial/ajax header and the jakarta.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 injected FacesContext can 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 the SystemTest stage as a switch for fixtures.

Common Mistakes

  • Sleeping instead of waiting. Thread.sleep after 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_xy ids change when a component is added before it. Give the component an id, and use pt:data-testid or 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 (the ExternalContext, a @RequestMap Map) instead, and the test stays short.

  • Static state between tests. A @ApplicationScoped in-memory repository keeps changes from one test to the next. The test project resets it through the SystemTest fixture; a unit test constructs a fresh object.

  • A shared Tomcat directory after a profile change. A leftover target/webapp mixed 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 Production configuration 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.

References