Testing

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

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

This page is written against Apache Struts 7.4.0 (the current 7.x release, verified on 2026-10-08), running on Apache Tomcat 11.0.26 and Java 21. The test classes are built with Maven, JUnit 6.1.3, struts2-junit-plugin and struts2-testng-plugin 7.4.0, Spring 6.2.19 (the mock servlet classes the plugins bring along), TestNG 7.12.0 and HtmlUnit 5.5.0, and every test shown was run: 32 JUnit tests in the main scratch project (three of them, the HtmlUnit tests, only when a deployed WAR is given), two TestNG tests and one Spring test in their own projects. The outputs and messages quoted are the ones those runs produced. The running scenario is the Bookshelf of Part II: a list of titles, a form that adds a title, a reader with a session, and an audit header added by an interceptor. The other pages of this part are linked where they explain the feature under test.

What to Test and at Which Level

The official advice is short: the recommended way to test actions is to instantiate the action classes and test them, and the JUnit plugin is for the cases where the action must run inside a Struts invocation, with the interceptors, the parameter binding and the result. That advice gives the levels below, from the cheapest to the most realistic. Each one catches a class of mistakes that the cheaper levels cannot see.

flowchart TD subgraph fast["No container, milliseconds"] L1["1 Action as a plain object"] --> L2["2 Interceptor with MockActionInvocation"] L2 --> L3["3 Struts invocation: StrutsJUnit4TestCase"] end subgraph slow["Real container, seconds"] L4["4 Rendered page: HtmlUnit"] --> L5["5 Real browser: Playwright, Cypress"] end L3 --> L4
Table 1. The five levels of Struts testing
Level What it runs What it can see What it cannot see

1 Plain object

The action class with new, plain JUnit

Logic of execute, getters and setters, validate that does not call getText

Parameter binding, @StrutsParameter, validation XML, interceptors, results, messages

2 Interceptor with mocks

The interceptor with MockActionInvocation and MockActionProxy from the core jar

The interceptor’s own decisions, with a fake invocation

The real stack order, the real action

3 Struts invocation

The real configuration, the interceptor stack, the action, the result type, with Spring’s mock request and response

Mapping, binding, validation, input results, redirects, session, locale, FreeMarker output, headers set before the result

JSP rendering, servlet filters, web.xml, real HTTP, the container’s session and security

4 Rendered page

A WAR in a real container, driven by HtmlUnit (a browser without a window)

The HTML the user gets, JSP and tags, headers sent over HTTP, links and forms

JavaScript-heavy behaviour, real rendering and layout

5 Real browser

Playwright, Cypress or Selenium on a deployed application

Everything the user sees, including scripts and CSS

Nothing, but it is the slowest and most fragile level

Level 5 is the subject of End-to-End Testing with Real Browsers and of issue #258 (Playwright, Cypress and Selenium); this page names it and stops there. The Java testing tools themselves (JUnit, Mockito, assertions) are on Testing in Java, and the Spring Boot counterpart of levels 3 and 4 is Unit and Integration Testing in Spring Boot. For JSP-only applications the equivalent page is Testing and Debugging JSP.

The Bookshelf Under Test

The scratch project is the bookshelf-struts base of this part: struts2-core 7.4.0 through the struts2-bom, the Servlet and Pages APIs as provided, Log4j 2, and the filter StrutsPrepareAndExecuteFilter mapped to /*. Four actions and one interceptor are enough to show every technique. The list action filters by a filter parameter and has a FreeMarker result; the add action validates a title and a year and redirects on success; the reader action counts visits in the session; the audit interceptor puts a header on the response.

public class BookListAction extends ActionSupport {

    private String filter = "";

    public String getFilter() { return filter; }

    @StrutsParameter
    public void setFilter(String filter) { this.filter = filter; }

    @Override
    public String execute() { return SUCCESS; }

    public List<Book> getBooks() {
        return List.of(
                new Book("Dune", "Frank Herbert", 1965),
                new Book("Neuromancer", "William Gibson", 1984),
                new Book("Hyperion", "Dan Simmons", 1989)).stream()
                .filter(b -> b.getTitle().toLowerCase().contains(filter.toLowerCase()))
                .toList();
    }
}
public class AddBookAction extends ActionSupport {

    private String title;
    private int year;

    // getters, and setters annotated with @StrutsParameter

    @Override
    public void validate() {
        if (title == null || title.isBlank()) {
            addFieldError("title", getText("title.required"));
        }
        if (year < 1450 || year > 2100) {
            addFieldError("year", getText("year.range"));
        }
    }

    @Override
    public String execute() { return SUCCESS; }
}
public class ReaderAction extends ActionSupport implements SessionAware {

    private Map<String, Object> session;

    @Override
    public void withSession(Map<String, Object> session) { this.session = session; }

    public int getVisits() { return (Integer) session.getOrDefault("visits", 0); }

    @Override
    public String execute() {
        session.put("visits", getVisits() + 1);
        return SUCCESS;
    }
}
<struts>
    <constant name="struts.allowlist.packageNames" value="org.example.bookshelf"/>

    <package name="bookshelf" extends="struts-default" namespace="/">
        <interceptors>
            <interceptor name="audit" class="org.example.bookshelf.AuditInterceptor"/>
            <interceptor-stack name="auditedStack">
                <interceptor-ref name="audit"/>
                <interceptor-ref name="defaultStack"/>
            </interceptor-stack>
        </interceptors>
        <default-interceptor-ref name="auditedStack"/>

        <action name="books" class="org.example.bookshelf.BookListAction">
            <result type="freemarker">/templates/books.ftl</result>
        </action>
        <action name="shelf" class="org.example.bookshelf.BookListAction">
            <result>/WEB-INF/views/shelf.jsp</result>
        </action>
        <action name="add" class="org.example.bookshelf.AddBookAction">
            <result type="redirectAction">books</result>
            <result name="input" type="freemarker">/templates/add.ftl</result>
        </action>
        <action name="reader" class="org.example.bookshelf.ReaderAction">
            <result type="freemarker">/templates/reader.ftl</result>
        </action>
    </package>
</struts>

The messages live in package.properties next to the action classes (org/example/bookshelf/package.properties for the English text and package_es.properties for Spanish), and the two FreeMarker templates print the books and the field errors. The books action has a FreeMarker result on purpose: it is the one result type that the mock environment of level 3 can render, and a JSP twin of it (shelf) shows where level 3 stops. The page on actions explains the classes, the page on interceptors the stack, and results and result types the redirectAction and freemarker results.

Level 1: Actions as Plain Objects

An action is a POJO, so the cheapest test needs neither Struts nor a container: create the object, set the properties the way the framework would, call the method, assert. This is what the Struts documentation recommends first, and it is exactly as fast as any other unit test. The tests below use JUnit Jupiter only.

class BookActionsPojoTest {

    @Test
    void listReturnsSuccessAndAllBooks() {
        BookListAction action = new BookListAction();

        assertEquals(ActionSupport.SUCCESS, action.execute());
        assertEquals(3, action.getBooks().size());
    }

    @Test
    void filterNarrowsTheShelf() {
        BookListAction action = new BookListAction();
        action.setFilter("dun");

        assertEquals("Dune", action.getBooks().get(0).getTitle());
        assertEquals(1, action.getBooks().size());
    }

    @Test
    void readerCountsVisitsInTheSessionMap() {
        ReaderAction action = new ReaderAction();
        Map<String, Object> session = new HashMap<>();
        action.withSession(session);

        action.execute();
        action.execute();

        assertEquals(2, session.get("visits"));
    }

    @Test
    void addActionIsValidWhenTitleAndYearAreFine() {
        AddBookAction action = new AddBookAction();
        action.setTitle("Hyperion");
        action.setYear(1989);

        action.validate();

        assertFalse(action.hasErrors());
        assertEquals(ActionSupport.SUCCESS, action.execute());
    }
}

The SessionAware action receives a plain Map, so the test passes a HashMap and reads it afterwards; this is the reason the Aware interfaces of Struts 7 hand over maps instead of servlet objects (see action chaining, sessions and scopes). The last test passes only because both values are valid: the validate method of AddBookAction calls getText only when something is wrong. With an invalid value the same method throws, which is the first pitfall of this level.

    @Test
    void validateNeedsTheFrameworkBecauseOfGetText() {
        AddBookAction action = new AddBookAction();

        NullPointerException e = assertThrows(NullPointerException.class, action::validate);
        System.out.println("POJO validate threw " + e.getMessage());
    }

Output of that test:

POJO validate threw Cannot invoke "org.apache.struts2.ActionContext.getContainer()" because the return value of "org.apache.struts2.ActionContext.getContext()" is null

ActionSupport.getText reaches the text provider through the ActionContext of the current thread, and a new object has none. There are three ways out: keep validate free of getText (return keys and resolve them in the view), build the action with the framework’s container (createAction, shown at level 3), or test the rule at level 3 where the whole invocation exists. The same NullPointerException is listed in the pitfalls of the page on actions.

Level 2: Interceptors with Mock Invocations

The core jar ships org.apache.struts2.mock.MockActionInvocation and MockActionProxy, two hand-made implementations of ActionInvocation and ActionProxy (the package also has MockInterceptor, MockResult and MockContainer). They let an interceptor run without a configuration: invoke() of the mock simply returns the result code that the test set. The interceptor under test is the audit one of the Bookshelf, which writes a header with the action name and the result code just before the result runs.

public class AuditInterceptor extends AbstractInterceptor {

    @Override
    public String intercept(ActionInvocation invocation) throws Exception {
        invocation.addPreResultListener((inv, resultCode) -> {
            HttpServletResponse response = inv.getInvocationContext().getServletResponse();
            if (response != null) {
                response.setHeader("X-Bookshelf-Audit", inv.getProxy().getActionName() + ":" + resultCode);
            }
        });
        return invocation.invoke();
    }
}
class AuditInterceptorTest {

    @Test
    void headerNamesTheActionAndItsResult() throws Exception {
        MockHttpServletResponse response = new MockHttpServletResponse();
        MockActionProxy proxy = new MockActionProxy();
        proxy.setActionName("books");
        MockActionInvocation invocation = new MockActionInvocation();
        invocation.setProxy(proxy);
        invocation.setResultCode("success");
        invocation.setInvocationContext(ActionContext.of().withServletResponse(response));

        String result = new AuditInterceptor().intercept(invocation);

        assertEquals("success", result);
        assertEquals("books:success", response.getHeader("X-Bookshelf-Audit"));
    }
}

The mock’s invoke() runs the pre-result listeners that the interceptor registered, with the result code the test preset, and returns that code; it executes no action and no result. The test therefore proves the interceptor’s own logic and nothing about the real stack. The same test also passed with an earlier version of the interceptor that set the header after invoke(), which is wrong on a real container; that version is the subject of the level 4 section below, and the reason a level 2 test never replaces a level 3 or 4 one. How to write the interceptors themselves is on custom interceptors.

The Struts JUnit Plugin: Setup

The plugin struts2-junit-plugin runs a full Struts instance inside the test JVM. The Struts documentation describes it as supporting tests of actions within a Struts invocation, a full simulated request whose output can be tested. It is a test dependency and never belongs in WEB-INF/lib:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.apache.struts</groupId>
            <artifactId>struts2-bom</artifactId>
            <version>7.4.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <dependency>
            <groupId>org.junit</groupId>
            <artifactId>junit-bom</artifactId>
            <version>6.1.3</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.apache.struts</groupId>
        <artifactId>struts2-junit-plugin</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.junit.vintage</groupId>
        <artifactId>junit-vintage-engine</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.5.6</version>
            <configuration>
                <argLine>-Duser.language=en -Duser.country=US</argLine>
            </configuration>
        </plugin>
    </plugins>
</build>

Three details of that file matter.

  • The plugin’s base classes are JUnit 4 and JUnit 3 classes, so a project that uses JUnit Jupiter needs the Vintage engine (junit-vintage-engine) to run them. Surefire 3.5.6 with the 6.1.3 platform ran Jupiter, JUnit 4 and JUnit 3 tests in one Maven run. Without the engine the build did not fail: the same project with the engine removed ran only the 6 Jupiter tests (Tests run: 6) and ended in BUILD SUCCESS, silently skipping every plugin-based test class.

  • The plugin brings its own test dependencies. The resolved dependency tree of the scratch project showed spring-test, spring-core, spring-context, spring-web (6.2.19) and junit:junit 4.13.2 with hamcrest-core 1.3, all in test scope. The mock request and response used throughout this page are Spring’s org.springframework.mock.web.MockHttpServletRequest and MockHttpServletResponse. The Spring plugin of Struts (struts2-spring-plugin) was not in the tree and must be added for Spring tests.

  • The argLine pins the language of the test JVM, for a reason shown in the pitfalls: on the machine that ran these tests the JVM default locale is Spanish, and a missing English bundle then fell back to the Spanish one.

The plugin has six base classes in org.apache.struts2.junit (verified with javap on the 7.4.0 jar), and a seventh pair in the TestNG plugin.

Table 2. Base classes of the JUnit and TestNG plugins
Class Style Use

StrutsJUnit4TestCase<T>

JUnit 4 (@Test)

The usual choice. Has getAction() and containsErrors() typed by T, and finishExecution() to start a second request

StrutsTestCase

JUnit 3 (extends junit.framework.TestCase, methods named testXxx)

Older code. Has createAction(Class) and applyAdditionalParams

StrutsRestTestCase<T>

JUnit 4

Tests of REST mappings; has executeAction(uri, content) and getActionProxy(uri, content) (covered on the REST plugin page, not run on this page)

StrutsSpringTestCase

JUnit 3

Loads a Spring application context before Struts (see below)

StrutsSpringJUnit4TestCase

JUnit 4

The same, in JUnit 4 style

XWorkJUnit4TestCase

JUnit 4

Only the Struts configuration and container, no servlet mocks: for tests of non-web components such as validators and converters

TestNGStrutsTestCase, TestNGXWorkTestCase (org.apache.struts2.testng)

TestNG

See the TestNG section below

Level 3: Running an Action Inside Struts

A test class extends StrutsJUnit4TestCase<T>, where T is the action class most tests deal with (it only types getAction()). Before each test the base class creates fresh Spring mocks (request, response, servletContext, pageContext), starts a Dispatcher with the project’s struts.xml and plugins, and after each test it shuts the dispatcher down. The test sets parameters on the request field and then asks for a mapping, a proxy or the output.

Table 3. The helper methods of the base classes
Member What it does

request, response, servletContext, pageContext

Spring mocks, created for every test. Parameters, headers, locales and session attributes are set on request before the call

getActionMapping(String url)

Resolves the namespace, name and method that a URL would reach, without running anything

getActionProxy(String uri)

Builds a proxy for the URL, with the request parameters bound to the invocation. proxy.execute() runs the interceptors, the action and the result, and returns the result code (success, input)

executeAction(String uri)

Runs the whole request through Dispatcher.serviceAction and returns the text written to the response. Throws ServletException when the status is not 200

findValueAfterExecute(String)

Evaluates an OGNL expression on the value stack of the last execution

getAction() and containsErrors() (JUnit 4 class)

The executed action; containsErrors is true only when the action has action errors

initSession(ActionContext) and initActionContext(ActionContext)

Hooks to adjust the context the proxy runs in

finishExecution() (JUnit 4 class)

Replaces request and response with fresh mocks and copies the session attributes, so that a second request sees the first one’s session

getConfigPath()

Returns a comma-separated list of configuration files to load instead of struts.xml

initDispatcherParams() and dispatcherInitParams

Parameters of the dispatcher, as the init parameters of the filter would be

injectStrutsDependencies(Object) and createAction(Class)

Inject the framework’s @Inject dependencies into an object (createAction is on the JUnit 3 and TestNG bases)

The mock environment is shown below. The test runs the real configuration and stack; what it replaces is the servlet container.

flowchart LR T["Test method"] --> R["MockHttpServletRequest: parameters, session, locale"] R --> D["Dispatcher.serviceAction"] D --> S["Interceptor stack"] S --> A["Action"] A --> X["Result: FreeMarker, redirect"] X --> P["MockHttpServletResponse"] P --> T subgraph out["Not part of the test"] F["Servlet filters"] W["web.xml, container security"] J["JSP rendering"] end

Mapping and Proxy

public class BookshelfStrutsTest extends StrutsJUnit4TestCase<BookListAction> {

    @Test
    public void mappingIsResolvedFromTheUrl() {
        ActionMapping mapping = getActionMapping("/books.action");

        assertNotNull(mapping);
        assertEquals("/", mapping.getNamespace());
        assertEquals("books", mapping.getName());
    }

    @Test
    public void proxyExecutesAndExposesTheAction() throws Exception {
        ActionProxy proxy = getActionProxy("/books.action");

        assertEquals("success", proxy.execute());
        BookListAction action = (BookListAction) proxy.getAction();
        assertEquals(3, action.getBooks().size());
    }
}

The first test checks that the URL maps to the namespace and action the configuration declares, which catches a typo in struts.xml or a wrong namespace (see packages and namespaces). The second runs the action through the stack; proxy.getAction() gives the object that the stack populated. Note the package of ActionProxy and ActionMapping: in Struts 7 they are org.apache.struts2.ActionProxy and org.apache.struts2.dispatcher.mapper.ActionMapping.

Parameters Through the Stack

Request parameters are set on the request before the proxy is built; the stack binds them like a real request, including the @StrutsParameter check.

    @Test
    public void parametersAreInjectedThroughTheInterceptorStack() throws Exception {
        request.setParameter("filter", "neuro");
        ActionProxy proxy = getActionProxy("/books.action");

        proxy.execute();

        BookListAction action = (BookListAction) proxy.getAction();
        assertEquals("neuro", action.getFilter());
        assertEquals(1, action.getBooks().size());
    }

The order matters: a parameter set after getActionProxy is not seen. A setter without @StrutsParameter would leave the property unset in this test as it does in production (see parameter injection and @StrutsParameter), which makes the test a useful guard for the annotation.

The Rendered Output and the Value Stack

executeAction returns what the result wrote to the response. For a FreeMarker result this is the rendered text.

    @Test
    public void executeActionReturnsTheRenderedFreeMarkerOutput() throws Exception {
        String output = executeAction("/books.action");

        assertTrue(output.contains("<li>Dune (1965)</li>"));
        assertTrue(response.getContentType().startsWith("text/html;"));
    }

    @Test
    public void valueStackCanBeInspectedAfterExecution() throws Exception {
        request.setParameter("filter", "hyp");
        executeAction("/books.action");

        assertEquals("hyp", findValueAfterExecute("filter"));
        BookListAction action = getAction();
        assertEquals(1, action.getBooks().size());
    }

    @Test
    public void proxyExecutionAlsoWritesTheResultIntoTheMockResponse() throws Exception {
        getActionProxy("/books.action").execute();

        assertTrue(response.getContentAsString().contains("<li>Hyperion (1989)</li>"));
    }

The rendered text of the first test, as printed by the run, was a list with the three titles (<ul>, one <li> per book, </ul>), and the content type of the mock response was text/html; charset=UTF-8 with a space after the semicolon, the reason the test uses startsWith("text/html;") and not an equality on the header. findValueAfterExecute accepts any OGNL expression, so books[0].title and filter both work. The third test shows that a proxy also executes the result (the proxy created by the base class has its executeResult flag on), so the output can be read from the mock response without executeAction.

Validation: input and Field Errors

A validation test needs no browser: leave out the parameters that should fail and check the result code and the field errors, as the official tutorial does with ActionSupport.INPUT. This is the test that level 1 could not write for AddBookAction, because the stack supplies the ActionContext that getText needs.

public class AddBookStrutsTest extends StrutsJUnit4TestCase<AddBookAction> {

    @Test
    public void validationFailureReturnsInputAndFieldErrors() throws Exception {
        request.setParameter("year", "1200");
        ActionProxy proxy = getActionProxy("/add.action");

        assertEquals("input", proxy.execute());
        AddBookAction action = (AddBookAction) proxy.getAction();
        assertEquals("The title is required", action.getFieldErrors().get("title").get(0));
        assertEquals("The year must be between 1450 and 2100", action.getFieldErrors().get("year").get(0));
    }

    @Test
    public void errorsAreRenderedByTheInputTemplate() throws Exception {
        String output = executeAction("/add.action");

        assertTrue(output.contains("title: The title is required"));
        assertTrue(output.contains("year: The year must be between 1450 and 2100"));
    }
}

The validation rules may live in validate(), in an XML file or in annotations; the test is the same for all, as the tutorial notes (see validation for the three ways). The second test renders the input result through the FreeMarker template, so it also checks that the template shows the errors.

One method of the base class does not do what its name suggests for this case:

    @Test
    public void containsErrorsOnlyLooksAtActionErrors() throws Exception {
        executeAction("/add.action");

        assertTrue(getAction().hasFieldErrors());
        assertFalse(containsErrors());
    }

containsErrors() reads hasActionErrors() of the action (the source of 7.4.0 says so), and it throws UnsupportedOperationException for an action that is not ValidationAware. After a failed validation, which adds field errors, it returns false. Use hasFieldErrors() or hasErrors() on the action instead.

Redirects: Proxy Versus executeAction

The add action redirects to the list on success. The two ways of calling it behave differently.

    @Test
    public void validRequestRedirectsToTheList() throws Exception {
        request.setParameter("title", "Hyperion");
        request.setParameter("year", "1989");
        ActionProxy proxy = getActionProxy("/add.action");

        assertEquals("success", proxy.execute());
        assertEquals(302, response.getStatus());
        assertEquals("/books.action", response.getRedirectedUrl());
        assertFalse(((AddBookAction) proxy.getAction()).hasErrors());
    }

    @Test
    public void executeActionTurnsARedirectIntoAServletException() throws Exception {
        request.setParameter("title", "Hyperion");
        request.setParameter("year", "1989");
        try {
            executeAction("/add.action");
            fail("expected a ServletException");
        } catch (ServletException e) {
            assertEquals("Error code [302], Error: [null]", e.getMessage());
            assertEquals("/books.action", response.getRedirectedUrl());
        }
    }

executeAction ends with a check that the response status is 200 and throws a ServletException with the text Error code [302], Error: [null] for any other status, a redirect included (the check is in the source of the base class). The status 404 of an unmapped URL and the 500 of an unhandled error end the same way. For a redirect test, use the proxy and read response.getRedirectedUrl(); the value was /books.action for the redirectAction result books. The redirect result types are on results and result types.

Sessions and Several Requests

The session is the HttpSession of the mock request, seen by the action as a map. Set an attribute before the call to start in a given state, and read it afterwards.

    @Test
    public void sessionStateIsSeenByTheAction() throws Exception {
        request.getSession().setAttribute("visits", 41);

        String output = executeAction("/reader.action");

        assertEquals("visits=42", output.trim());
        assertEquals(42, request.getSession().getAttribute("visits"));
    }

The output is visits=42 because the template renders after the action has counted the visit. A flow of two requests needs finishExecution() between them.

public class ReaderSessionStrutsTest extends StrutsJUnit4TestCase<ReaderAction> {

    @Test
    public void sessionIsCarriedToTheNextRequestByFinishExecution() throws Exception {
        assertEquals("visits=1", executeAction("/reader.action").trim());

        finishExecution();

        assertEquals("visits=2", executeAction("/reader.action").trim());
    }
}

Without finishExecution() the same request and response objects are reused: a second executeAction in the same test returned visits=1 followed by visits=2 in one string, because the mock response had not been replaced and kept the first body (the run printed second call without finishExecution: [visits=1|visits=2]). The scopes themselves are explained on action chaining, sessions and scopes.

Locale and Messages

The messages of an action come from the resource bundle of its package, chosen by the locale. In the mock environment the locale is the one of the request.

    @Test
    public void messagesFollowTheRequestLocale() throws Exception {
        request.addPreferredLocale(Locale.of("es"));

        String output = executeAction("/add.action");

        assertTrue(output.contains("title: El titulo es obligatorio"));
    }

The default tests above pass with the argLine of the setup section and give the English messages; with Spanish selected by Locale.of("es") the same request gave El titulo es obligatorio. The bundles and the i18n interceptor are on localization and i18n.

Interceptors Through the Stack

Because the default stack of the package is the audited one, any executeAction runs the audit interceptor and the header can be read from the mock response. The test also covers the stack order: the header is there only if the interceptor is in the stack the action uses.

    @Test
    public void interceptorAddsTheAuditHeader() throws Exception {
        executeAction("/books.action");

        assertEquals("books:success", response.getHeader("X-Bookshelf-Audit"));
    }

The tutorial notes the same technique for a custom interceptor: the test of an interceptor’s methods can run through the interceptor stack of an action. The configuration of stacks is on interceptors.

The JSP Result Is Not Rendered

The mock environment has no JSP engine. The shelf action has a JSP result, and executing it returned a status 200 with an empty body:

    @Test
    public void jspResultIsNotRenderedByTheMockEnvironment() throws Exception {
        String output = executeAction("/shelf.action");

        assertEquals("", output);
        assertEquals(200, response.getStatus());
    }

The official page says the same: with JSPs as the template engine, the output cannot be tested outside the container (older documentation pointed at the Embedded JSP plugin for this, which is no longer published for 7.x; the plugin index of the plugins page lists it as deprecated since 6.0.0 and the 7.4.0 artifact is not on Maven Central). A test that needs the JSP markup runs at level 4. Result types that render without a container, FreeMarker and Velocity, are on FreeMarker and Velocity views.

A Separate Configuration for Tests

By default the base class loads struts.xml and the plugins' files from the classpath. A test that wants only a handful of mappings overrides getConfigPath(), which the base class turns into the config parameter of the dispatcher as struts-default.xml followed by the given files.

public class CustomConfigStrutsTest extends StrutsJUnit4TestCase<ReaderAction> {

    @Override
    protected String getConfigPath() {
        return "struts-test.xml";
    }

    @Test
    public void onlyTheTestConfigurationIsLoaded() throws Exception {
        ActionMapping mapping = getActionMapping("/test/ping.action");
        assertNotNull(mapping);
        assertEquals("/test", mapping.getNamespace());

        assertEquals("visits=1", executeAction("/test/ping.action").trim());
    }
}

with src/test/resources/struts-test.xml declaring a package test in the namespace /test with one action ping. The test passed; the production struts.xml was not loaded in it, so its mappings (books, add) were not available. The configuration files and the constants they set are described on configuration: struts.xml and constants.

The JUnit 3 Style Class

StrutsTestCase is the JUnit 3 variant, the one the official examples use: no annotations, methods named testXxx, and the assertions inherited from TestCase. It has createAction(Class), which creates the object through the Struts container so that its @Inject dependencies, and with them getText, work.

public class LegacyStrutsTestCaseTest extends StrutsTestCase {

    public void testListIsSuccessful() throws Exception {
        ActionProxy proxy = getActionProxy("/books.action");
        assertEquals("success", proxy.execute());
    }

    public void testCreateActionInjectsDependencies() throws Exception {
        AddBookAction action = createAction(AddBookAction.class);
        action.validate();
        assertTrue(action.hasFieldErrors());
        assertEquals("The title is required", action.getFieldErrors().get("title").get(0));
    }
}

The second test is the answer to the NullPointerException of level 1: the action built by createAction has its framework dependencies, so validate can call getText and a validation rule can be tested without a request. The class compiles and runs under the Vintage engine like the JUnit 4 ones.

The Test Environment Is More Permissive Than Production

The helper that starts the dispatcher for the plugins, StrutsTestCaseHelper.initDispatcher, sets struts.allowlist.enable to false unless the test passes its own value (the line is finalParams.putIfAbsent(STRUTS_ALLOWLIST_ENABLE, "false") in the 7.4.0 source). The OGNL allowlist is the default in production since Struts 7.0, so the tests ran without the protection that the application has in production: the run printed the warning OGNL allowlist is disabled! We strongly recommend keeping it enabled for each test. The 6.12.0 version of the helper has no such line.

A test that evaluates OGNL on the stack, or a JSP rendered later, can therefore pass in the test and fail in production. To test with the production setting, pass the constant to the dispatcher by overriding initDispatcherParams:

public class AllowlistStrutsTest extends StrutsJUnit4TestCase<BookListAction> {

    @Override
    protected void initDispatcherParams() {
        dispatcherInitParams = new HashMap<>();
        dispatcherInitParams.put("struts.allowlist.enable", "true");
    }

    @Test
    public void ognlOnTheStackWorksWithTheProductionAllowlist() throws Exception {
        executeAction("/books.action");

        assertEquals("Dune", findValueAfterExecute("books[0].title"));
    }
}

The test passes because struts.xml allowlists the package of the Bookshelf classes (struts.allowlist.packageNames). Removing that constant made the same test fail with a null value and two warnings from the run:

WARN  SecurityMemberAccess - Declaring class [class org.example.bookshelf.Book] of member type [public java.lang.String org.example.bookshelf.Book.getTitle()] is not allowlisted! Add to 'struts.allowlist.classes' or 'struts.allowlist.packageNames' configuration.
WARN  SecurityMemberAccess - Access to non-public [private final java.lang.String org.example.bookshelf.Book.title] is blocked!

This is the same message a missing allowlist entry produces in production (see the value stack, the action context and OGNL and the parameter binding page), and the reason it is worth one test class that runs with the allowlist on. The security guide describes the allowlist capability and its constants.

JUnit Jupiter Without the Plugin Base Classes

Projects that have no JUnit 4 on the classpath can still start Struts from a Jupiter test, because the helper that the base classes use is a public class of the core jar. The test below uses the helper to start and stop the dispatcher, creates a proxy through the container’s ActionProxyFactory with the parameters in an ActionContext, and binds the context. It needs the Spring mock classes (spring-test, which the JUnit plugin would otherwise bring) for the servlet objects that the i18n and other interceptors read.

class AddBookJupiterTest {

    private final MockServletContext servletContext = new MockServletContext();
    private Dispatcher dispatcher;

    @BeforeEach
    void startStruts() {
        dispatcher = StrutsTestCaseHelper.initDispatcher(servletContext, null);
    }

    @AfterEach
    void stopStruts() {
        StrutsTestCaseHelper.tearDown(dispatcher);
    }

    private ActionProxy proxyFor(String name, Map<String, String[]> parameters) {
        ActionContext context = ActionContext.of(new HashMap<>())
                .withParameters(HttpParameters.create(new HashMap<>(parameters)).build())
                .withSession(new HashMap<>())
                .withServletContext(servletContext)
                .withServletRequest(new MockHttpServletRequest(servletContext))
                .withServletResponse(new MockHttpServletResponse());
        ActionProxy proxy = dispatcher.getContainer().getInstance(ActionProxyFactory.class)
                .createActionProxy("/", name, null, context.getContextMap(), false, false);
        ActionContext.bind(proxy.getInvocation().getInvocationContext());
        return proxy;
    }

    @Test
    void invalidFormGoesBackToInput() throws Exception {
        ActionProxy proxy = proxyFor("add", Map.of("year", new String[] {"1200"}));

        assertEquals("input", proxy.execute());
        AddBookAction action = (AddBookAction) proxy.getAction();
        assertTrue(action.getFieldErrors().containsKey("title"));
        assertTrue(action.getFieldErrors().containsKey("year"));
    }

    @Test
    void validFormSucceeds() throws Exception {
        ActionProxy proxy = proxyFor("add",
                Map.of("title", new String[] {"Hyperion"}, "year", new String[] {"1989"}));

        assertEquals("success", proxy.execute());
    }
}

Both tests passed. The first attempt, without the servlet request in the context, failed with a NullPointerException in SessionLocaleHandler.read (the i18n interceptor calls ServletActionContext.getRequest().getSession(false)), which is why the context carries a mock request, response and servlet context. This is a plumbing that the base classes hide; for most projects the Vintage engine is the shorter road, and this variant is for teams that want the Struts-specific code in one helper of their own. The helper is a documented class of the core Javadoc but not described in the plugin pages, so treat it as an internal API that can change.

Spring: StrutsSpringTestCase

When the application uses Spring as the object factory (see Spring integration), the test must start the Spring context first. StrutsSpringTestCase does it: the context is loaded from classpath*:applicationContext.xml unless getContextLocations() is overridden, stored in the servlet context, and available in the static field applicationContext. The Spring plugin must be on the test classpath (it is not in the tree of the JUnit plugin).

public class CountActionSpringTest extends StrutsSpringTestCase {

    public void testCountComesFromTheSpringBean() throws Exception {
        assertEquals("count=3", executeAction("/count.action").trim());
        assertTrue(applicationContext.containsBean("catalogue"));
    }
}

with struts2-spring-plugin as a test dependency, a Catalogue bean in src/test/resources/applicationContext.xml, and an action count whose setCatalogue receives the bean by name. The test passed. The loading is done once per JVM (the field is static and checked for null in the 7.4.0 source), so two test classes of one run share one context, which is a speed advantage and a source of leaked state. The official page also notes that the default location can be changed by overriding getContextLocations.

The TestNG Plugin

The TestNG plugin gives TestNG users a base class that starts a Struts instance. It is much smaller than the JUnit one. The 7.4.0 source of TestNGStrutsTestCase has a setUp annotated @BeforeTest, an initDispatcher, a createAction(Class) and a tearDown annotated @AfterTest; it has no mock request, no executeAction and no getActionProxy. The fields that it gives are the Struts configuration, configurationManager and container. It is a test dependency and not for WEB-INF/lib, as the plugin page says.

<dependency>
    <groupId>org.apache.struts</groupId>
    <artifactId>struts2-testng-plugin</artifactId>
    <scope>test</scope>
</dependency>
public class BookshelfTestNGTest extends TestNGStrutsTestCase {

    @Test
    public void actionIsConfiguredInStrutsXml() {
        ActionConfig config = configuration.getRuntimeConfiguration().getActionConfig("/", "add");

        assertNotNull(config);
        assertEquals(config.getClassName(), "org.example.bookshelf.AddBookAction");
        assertTrue(config.getResults().containsKey("input"));
    }

    @Test
    public void createActionInjectsFrameworkDependencies() {
        AddBookAction action = createAction(AddBookAction.class);

        action.validate();

        assertEquals(action.getFieldErrors().get("title").get(0), "The title is required");
    }
}

Both tests passed in a project that has the TestNG plugin and no JUnit plugin: Surefire selected the TestNG provider and the resolved tree had testng 7.12.0 and spring-test 6.2.19 under the plugin. Two points deserve attention. TestNG puts the expected value second in assertEquals, the reverse of JUnit. And @BeforeTest runs once before the tests of a <test> element of the suite, not before each method as JUnit’s @Before does, so the Struts instance is shared by all the test methods of the run; do not leave state in the container between tests. The first test shows the cheapest and most useful use of the plugin: checking that a mapping of struts.xml points at the intended class and has the intended results, with no request at all. A test that needs a request and a response has to build the mocks itself, as the Jupiter example above does, or use the JUnit plugin.

Level 4: Rendered Pages in a Real Container

The mock environment cannot render JSP, does not run servlet filters and does not speak HTTP, so a few tests belong in a real container. The Bookshelf WAR was deployed on Tomcat 11.0.26 and tested with HtmlUnit 5.5.0, a Java browser without a window (package org.htmlunit since version 5). The tests are skipped unless the system property bookshelf.url names a deployed application, which keeps mvn test independent of a server.

class BookshelfHtmlUnitTest {

    private static final String BASE = System.getProperty("bookshelf.url");

    private WebClient webClient;

    @BeforeEach
    void openBrowser() {
        assumeTrue(BASE != null, "set -Dbookshelf.url to run against a deployed WAR");
        webClient = new WebClient();
        webClient.getOptions().setThrowExceptionOnFailingStatusCode(true);
    }

    @AfterEach
    void closeBrowser() {
        if (webClient != null) {
            webClient.close();
        }
    }

    @Test
    void shelfPageListsAllThreeBooks() throws Exception {
        HtmlPage page = webClient.getPage(BASE + "/shelf.action");

        assertEquals("Main shelf", page.getElementById("heading").getTextContent());
        assertEquals(3, page.getElementById("books").getElementsByTagName("li").size());
    }

    @Test
    void followingTheFilterLinkNarrowsTheShelf() throws Exception {
        HtmlPage page = webClient.getPage(BASE + "/shelf.action");

        HtmlPage filtered = ((HtmlAnchor) page.getElementById("newer")).click();

        assertEquals(1, filtered.getElementById("books").getElementsByTagName("li").size());
        assertTrue(filtered.asNormalizedText().contains("Dune (1965)"));
    }

    @Test
    void auditHeaderIsSentByTheRealContainer() throws Exception {
        var response = webClient.getPage(BASE + "/shelf.action").getWebResponse();

        assertEquals("shelf:success", response.getResponseHeaderValue("X-Bookshelf-Audit"));
    }
}

Run with mvn test -Dtest=BookshelfHtmlUnitTest -Dbookshelf.url=http://localhost:18080/g12testing (the scratch context name), the three tests passed. Without the property Surefire reported them as skipped. The link test follows a real href from the page and checks the second page, which no level 3 test could do for a JSP. The tags that produce these pages are on UI and form tags.

Level 4 found a defect that levels 2 and 3 had accepted. The first version of the audit interceptor set its header after invocation.invoke() returned:

        String result = invocation.invoke();
        response.setHeader("X-Bookshelf-Audit", invocation.getProxy().getActionName() + ":" + result);
        return result;

The unit test of level 2 passed (the mock never commits a response) and so did the executeAction test of level 3 (the mock response keeps late headers). With that version deployed on Tomcat, curl -i showed no X-Bookshelf-Audit header, and the HtmlUnit test failed with expected: <shelf:success> but was: <null>. The reason is that invoke() runs the result, which writes the page and commits the response; a header set afterwards is ignored. The version shown at level 2, which sets the header in a pre-result listener, is the one that produces the header on the real wire. The general lesson is on the page about custom interceptors; the testing lesson is that anything about the response must be asserted over HTTP at least once.

A second difference between levels 3 and 4 came from OGNL in the JSP. The first deployed version of the scratch project had Book as a Java record, and the page printed ` ()` for every book, while the mock tests had passed. The view used bare names such as title inside s:iterator, and the value stack’s search for a bare name finds only JavaBean getters, so it skips a record whatever the allowlist says; top.title, title() and a JSP EL expression printed the value (Java Records in OGNL has the full rule). The scratch project therefore uses a JavaBean Book and lists its package in struts.allowlist.packageNames, and the allowlist test of the previous section guards the rule. Embedded containers (Tomcat or Jetty started from the test), Testcontainers with the application’s image (see the Docker pages of this site, for example Docker) and Playwright for the real browser are the next steps up, and are covered by #258.

The Bookshelf End to End: Testing the Add Title Feature

Putting the levels together for the add a title feature of the Bookshelf, each rule is tested at the cheapest level that can see it, and one test at each higher level guards the wiring.

Table 4. Where each rule of the Bookshelf is tested
Rule Level Test

The list is filtered by filter, case-insensitively

1

BookActionsPojoTest.filterNarrowsTheShelf

The reader’s visit counter uses the session map

1

BookActionsPojoTest.readerCountsVisitsInTheSessionMap

The audit header names the action and the result

2, 3, 4

AuditInterceptorTest, BookshelfStrutsTest.interceptorAddsTheAuditHeader, BookshelfHtmlUnitTest.auditHeaderIsSentByTheRealContainer

A missing title and a year of 1200 give input and two field errors

3

AddBookStrutsTest.validationFailureReturnsInputAndFieldErrors

The messages are localised

3

AddBookStrutsTest.messagesFollowTheRequestLocale

A valid title redirects to /books.action

3

AddBookStrutsTest.validRequestRedirectsToTheList

filter reaches the action only with @StrutsParameter

3

BookshelfStrutsTest.parametersAreInjectedThroughTheInterceptorStack

The list view shows the titles

3 (FreeMarker), 4 (JSP)

executeActionReturnsTheRenderedFreeMarkerOutput, BookshelfHtmlUnitTest

OGNL access to Book works with the allowlist on

3

AllowlistStrutsTest

The filter link of the page works

4

followingTheFilterLinkNarrowsTheShelf

The final run of mvn test in the scratch project reported Tests run: 32, Failures: 0, Errors: 0, Skipped: 3 without a deployed WAR (the three skipped are the HtmlUnit tests) and Skipped: 0 with -Dbookshelf.url set; the whole suite took about three seconds without the HtmlUnit part. The cost is the reason for the pyramid: 29 of the 32 tests need no container.

Pitfalls

Table 5. Testing pitfalls, their symptoms and fixes (rows marked "observed" were reproduced on Struts 7.4.0)
Symptom or error Cause Fix

NullPointerException ... ActionContext.getContainer() when calling validate() on a new action (observed)

ActionSupport.getText needs the framework’s ActionContext, which a plain object lacks

Build the action with createAction, test at level 3, or keep getText out of the method

The build is green but the plugin-based test classes never run (observed: Tests run: 6 instead of 32)

The plugin’s base classes use JUnit 4 and 3; the Jupiter engine does not run them, and nothing reports the missing classes

Add junit-vintage-engine at the version of the JUnit platform in use (it ran with 6.1.3) and compare the test count with the number of test classes

A message test passes on one machine and fails on another, giving text in a different language (observed)

A missing package_en.properties makes ResourceBundle fall back to the bundle of the JVM’s default locale (Spanish here) before the base bundle

Pin the JVM locale (-Duser.language=en in the Surefire argLine), or add a bundle for every locale tested

ServletException: Error code [302], Error: [null] from executeAction (observed)

executeAction throws for any status other than 200, redirects included

For redirects and error statuses use getActionProxy, execute it, and read response.getRedirectedUrl() or response.getStatus()

The body of a JSP result is empty and the status is 200 (observed)

The mock environment has no JSP engine

Test the action and the result code at level 3; test the markup in a container with HtmlUnit

A parameter set on the request is not seen by the action

It was set after getActionProxy, which binds the parameters

Set every parameter first, then build the proxy

The second executeAction returns the first body as well (observed)

The same mock response is reused

Call finishExecution() between the requests of one test

containsErrors() is false after a failed validation (observed)

It checks action errors only, not field errors

Use action.hasFieldErrors() or action.hasErrors()

A test passes, but the application fails with a blocked OGNL expression

The helper starts the dispatcher with struts.allowlist.enable=false (observed: warning OGNL allowlist is disabled!)

Run one test class with struts.allowlist.enable=true through initDispatcherParams, and list the application’s packages in struts.allowlist.packageNames

A response header set after invocation.invoke() is in the mock response but not on the wire (observed)

The result has committed the response by then

Set headers before the result, for example in a PreResultListener, and assert them in an HTTP-level test

Two TestNG methods see each other’s state

@BeforeTest of TestNGStrutsTestCase runs once per suite <test>, not per method

Keep tests independent of the shared container, or start Struts in your own @BeforeMethod

Jupiter test fails with NullPointerException in SessionLocaleHandler.read (observed)

The ActionContext has no servlet request, and the i18n interceptor reads the session

Put a mock request, response and servlet context into the context, as the Jupiter example does

A Spring test sees state from an earlier class

StrutsSpringTestCase keeps the application context in a static field and loads it once

Reset the beans in @Before, or override getContextLocations per class

s:property with title inside s:iterator is empty for a Java record although top.title and title() work (observed)

A bare name is searched on the stack through JavaBean getters, which a record does not have (Java Records in OGNL)

Use a JavaBean or add getters, or write top.title, #b.title or title()

com.opensymphony.xwork2.ActionProxy or javax.servlet.ServletException does not compile

The example comes from a Struts 6 or older page

Use org.apache.struts2.ActionProxy and jakarta.servlet.ServletException

What Changed Recently

  • Package renames in Struts 7.0. All classes of the com.opensymphony.xwork2 package were moved into org.apache.struts2 and the servlet imports changed from javax.servlet to jakarta.servlet. Test code written for Struts 6 changes imports only; the 6.12.0 sources of the JUnit plugin import com.opensymphony.xwork2.ActionProxy and javax.servlet.ServletException, the 7.4.0 ones org.apache.struts2.ActionProxy and jakarta.servlet.ServletException. Base classes such as XWorkTestCase and XWorkJUnit4TestCase are in org.apache.struts2 in the core jar of 7.4.0.

  • The allowlist in tests. Since 7.0 the OGNL allowlist is enabled by default in production, and the test helper of 7.4.0 explicitly turns it off unless told otherwise; the 6.12.0 helper has no allowlist handling.

  • @StrutsParameter is required. Tests that set request parameters and read them from an action need the annotation on the setter in Struts 7 (struts.parameters.requireAnnotations is true); a test of the binding therefore also tests the annotation.

  • Aware interfaces take maps. SessionAware.withSession(Map) in org.apache.struts2.action replaces the older setSession; level 1 tests pass a HashMap.

  • JUnit 6. The JUnit platform is at 6.1.3 on Maven Central, with a matching junit-vintage-engine 6.1.3; the Struts plugin itself still builds on JUnit 4.13.2 and Spring 6.2.19 mocks, and its base classes did not change for 6.

  • HtmlUnit coordinates. The tests here use org.htmlunit:htmlunit 5.5.0 and the package org.htmlunit; examples that import com.gargoylesoftware.htmlunit belong to older releases.

  • Embedded JSP plugin. The plugin that let executeAction render JSPs is not published for 7.x; use a container for JSP.

Where the Books Are Outdated

The testing advice in Struts 2 in Action (Manning, 2008, the best-practices chapter) and the Struts 1 era material is about unit-testing actions as POJOs, and that advice is still the first level of this page. What changed around it:

Table 6. Older testing material against Struts 7.4
The book’s era Today (Struts 7.4)

Actions are tested as plain objects with JUnit 3 or 4

Still the recommended first step; JUnit Jupiter is the default of new projects. Methods that call getText need the framework’s context

com.opensymphony.xwork2.* imports in test code (ActionProxy, ActionContext, XWorkTestCase)

org.apache.struts2.*; the move happened in 7.0

javax.servlet mock objects from older Spring versions

jakarta.servlet mocks (Spring 6) through the JUnit plugin

Tests that run the full stack assume the OGNL expressions are unrestricted

The production allowlist restricts OGNL, and the test helper turns it off by default; enable it in at least one test class

JSP output expected from the test of an action

The mock environment renders only FreeMarker and Velocity; JSP markup is tested with HtmlUnit or a browser tool against a real container

Test helpers written for Struts 1 actions (ActionForm objects, ActionMapping and ActionForward mocks)

They do not apply to Struts 2: there is no form bean, the action’s properties are the form, and the test sets them with request.setParameter

Waiting for a servlet container to test validation

Validation runs at level 3 in milliseconds through getActionProxy

Notes on the Official Documentation

  • The JUnit plugin page’s example imports com.opensymphony.xwork2.ActionProxy, com.opensymphony.xwork2.Action and javax.servlet.ServletException; in 7.4.0 the classes are org.apache.struts2.ActionProxy and org.apache.struts2.action.Action, and the exception is jakarta.servlet.ServletException. The page’s mapping also sets struts.objectFactory to spring for a test that does not use Spring.

  • The same page’s template section lists applyAdditionalParams, createAction and injectStrutsDependencies as methods of the base class; in 7.4.0 the JUnit 4 base class StrutsJUnit4TestCase has injectStrutsDependencies but not createAction or applyAdditionalParams (they are in StrutsTestCase), and has getAction, containsErrors and finishExecution, which the page does not mention.

  • The tutorial’s RegisterTest extends StrutsTestCase and annotates its methods with @Test, a JUnit 4 annotation; in the 7.4.0 jar StrutsTestCase extends XWorkTestCase, which extends junit.framework.TestCase (JUnit 3), and the class for annotated JUnit 4 tests is StrutsJUnit4TestCase.

  • The TestNG plugin page says the plugin’s own dependencies are "spring-mock and testng"; the resolved dependency tree of 7.4.0 has testng 7.12.0, spring-test and spring-core 6.2.19, and no spring-mock.

  • The Getting Started unit-testing tutorial says the Struts JUnit plugin’s transitive dependencies are "numerous"; the tree of 7.4.0 lists five Spring modules, micrometer-observation, micrometer-commons and junit 4.13.2 with hamcrest-core 1.3.

  • No official page says that StrutsTestCaseHelper disables the OGNL allowlist for tests, nor that executeAction throws for a status other than 200; both were read from the source and reproduced.