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.
Source: Apache Struts — JUnit Plugin
| Level | What it runs | What it can see | What it cannot see |
|---|---|---|---|
1 Plain object |
The action class with |
Logic of |
Parameter binding, |
2 Interceptor with mocks |
The interceptor with |
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, |
JSP rendering, servlet filters, |
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>
Source: Apache Struts — JUnit Plugin
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 inBUILD 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) andjunit:junit4.13.2 withhamcrest-core1.3, all in test scope. The mock request and response used throughout this page are Spring’sorg.springframework.mock.web.MockHttpServletRequestandMockHttpServletResponse. The Spring plugin of Struts (struts2-spring-plugin) was not in the tree and must be added for Spring tests. -
The
argLinepins 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.
| Class | Style | Use |
|---|---|---|
|
JUnit 4 ( |
The usual choice. Has |
|
JUnit 3 ( |
Older code. Has |
|
JUnit 4 |
Tests of REST mappings; has |
|
JUnit 3 |
Loads a Spring application context before Struts (see below) |
|
JUnit 4 |
The same, in JUnit 4 style |
|
JUnit 4 |
Only the Struts configuration and container, no servlet mocks: for tests of non-web components such as validators and converters |
|
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.
| Member | What it does |
|---|---|
|
Spring mocks, created for every test. Parameters, headers, locales and session attributes are set on |
|
Resolves the namespace, name and method that a URL would reach, without running anything |
|
Builds a proxy for the URL, with the request parameters bound to the invocation. |
|
Runs the whole request through |
|
Evaluates an OGNL expression on the value stack of the last execution |
|
The executed action; |
|
Hooks to adjust the context the proxy runs in |
|
Replaces |
|
Returns a comma-separated list of configuration files to load instead of |
|
Parameters of the dispatcher, as the init parameters of the filter would be |
|
Inject the framework’s |
The mock environment is shown below. The test runs the real configuration and stack; what it replaces is the servlet container.
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.
Source: Apache Struts — JUnit Plugin
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.
Source: HtmlUnit — Getting Started
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.
| Rule | Level | Test |
|---|---|---|
The list is filtered by |
1 |
|
The reader’s visit counter uses the session map |
1 |
|
The audit header names the action and the result |
2, 3, 4 |
|
A missing title and a year of 1200 give |
3 |
|
The messages are localised |
3 |
|
A valid title redirects to |
3 |
|
|
3 |
|
The list view shows the titles |
3 (FreeMarker), 4 (JSP) |
|
OGNL access to |
3 |
|
The filter link of the page works |
4 |
|
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
| Symptom or error | Cause | Fix |
|---|---|---|
|
|
Build the action with |
The build is green but the plugin-based test classes never run (observed: |
The plugin’s base classes use JUnit 4 and 3; the Jupiter engine does not run them, and nothing reports the missing classes |
Add |
A message test passes on one machine and fails on another, giving text in a different language (observed) |
A missing |
Pin the JVM locale ( |
|
|
For redirects and error statuses use |
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 |
It was set after |
Set every parameter first, then build the proxy |
The second |
The same mock |
Call |
|
It checks action errors only, not field errors |
Use |
A test passes, but the application fails with a blocked OGNL expression |
The helper starts the dispatcher with |
Run one test class with |
A response header set after |
The result has committed the response by then |
Set headers before the result, for example in a |
Two TestNG methods see each other’s state |
|
Keep tests independent of the shared container, or start Struts in your own |
Jupiter test fails with |
The |
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 |
|
Reset the beans in |
|
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 |
|
The example comes from a Struts 6 or older page |
Use |
Source: Apache Struts — JUnit Plugin
What Changed Recently
-
Package renames in Struts 7.0. All classes of the
com.opensymphony.xwork2package were moved intoorg.apache.struts2and the servlet imports changed fromjavax.servlettojakarta.servlet. Test code written for Struts 6 changes imports only; the 6.12.0 sources of the JUnit plugin importcom.opensymphony.xwork2.ActionProxyandjavax.servlet.ServletException, the 7.4.0 onesorg.apache.struts2.ActionProxyandjakarta.servlet.ServletException. Base classes such asXWorkTestCaseandXWorkJUnit4TestCaseare inorg.apache.struts2in 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.
-
@StrutsParameteris required. Tests that set request parameters and read them from an action need the annotation on the setter in Struts 7 (struts.parameters.requireAnnotationsistrue); a test of the binding therefore also tests the annotation. -
Aware interfaces take maps.
SessionAware.withSession(Map)inorg.apache.struts2.actionreplaces the oldersetSession; level 1 tests pass aHashMap. -
JUnit 6. The JUnit platform is at 6.1.3 on Maven Central, with a matching
junit-vintage-engine6.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:htmlunit5.5.0 and the packageorg.htmlunit; examples that importcom.gargoylesoftware.htmlunitbelong to older releases. -
Embedded JSP plugin. The plugin that let
executeActionrender 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:
| 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 |
|
|
|
|
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 ( |
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 |
Waiting for a servlet container to test validation |
Validation runs at level 3 in milliseconds through |
Source: Apache Struts — JUnit Plugin
Notes on the Official Documentation
-
The JUnit plugin page’s example imports
com.opensymphony.xwork2.ActionProxy,com.opensymphony.xwork2.Actionandjavax.servlet.ServletException; in 7.4.0 the classes areorg.apache.struts2.ActionProxyandorg.apache.struts2.action.Action, and the exception isjakarta.servlet.ServletException. The page’s mapping also setsstruts.objectFactorytospringfor a test that does not use Spring. -
The same page’s template section lists
applyAdditionalParams,createActionandinjectStrutsDependenciesas methods of the base class; in 7.4.0 the JUnit 4 base classStrutsJUnit4TestCasehasinjectStrutsDependenciesbut notcreateActionorapplyAdditionalParams(they are inStrutsTestCase), and hasgetAction,containsErrorsandfinishExecution, which the page does not mention. -
The tutorial’s
RegisterTestextendsStrutsTestCaseand annotates its methods with@Test, a JUnit 4 annotation; in the 7.4.0 jarStrutsTestCaseextendsXWorkTestCase, which extendsjunit.framework.TestCase(JUnit 3), and the class for annotated JUnit 4 tests isStrutsJUnit4TestCase. -
The TestNG plugin page says the plugin’s own dependencies are "spring-mock and testng"; the resolved dependency tree of 7.4.0 has
testng7.12.0,spring-testandspring-core6.2.19, and nospring-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-commonsandjunit4.13.2 withhamcrest-core1.3. -
No official page says that
StrutsTestCaseHelperdisables the OGNL allowlist for tests, nor thatexecuteActionthrows for a status other than 200; both were read from the source and reproduced.
Source: Apache Struts — JUnit Plugin
Source: Apache Struts — TestNG Plugin