Testing and Debugging JSP Applications
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Jakarta Pages 4.0 (Jakarta EE 11), together with Jakarta Servlet 6.1, Jakarta Expression
Language 6.0 and Jakarta Standard Tag Library 3.0, as implemented by Apache Tomcat 11.0.26 (the Jasper engine) on Java 21,
with JUnit Jupiter 6.1.3, Mockito 5.23.0, HtmlUnit 5.5.0, Playwright for Java 1.63.0 and Spring Framework 7.0.9 for the
tests. Everything was verified on 2026-10-08: the facts against the Pages 4.0 specification (section 11.5) and the Tomcat 11
Jasper documentation, and every example by building it into one Maven project (WAR context g7testing, tests run with
mvn test: 8 tests in 6 classes, all green) and by running the pages on Tomcat 11.0.26 with curl; the outputs, generated
sources, SMAP tables and log lines shown are the ones that run produced. A JSP page is a servlet that was written in another
notation, so everything that is hard about debugging one comes from the translation step: the code that runs is not the code
that was typed. This page shows how to read that generated code, how line numbers are mapped back to the page, how to read the
errors Jasper reports, how to attach a debugger, and then how to build a test pyramid for pages: mocks for servlets and tag
handlers, an embedded Tomcat that renders the real page, HtmlUnit and Playwright for the browser view, and Spring MockMvc
for the Spring variant. The running scenario is the Bookshelf, a small catalogue of books. The Playwright and end-to-end
side is only touched here; Selenium, Playwright and Cypress are covered in depth on
End-to-End Testing with Real Browsers, and the page that covers
how a page becomes a class is JSP Lifecycle and Translation.
The Bookshelf Used on This Page
All the examples are files of one Maven project, g7testing, deployed as g7testing.war for the browser-side checks and run
without a server by the tests. It uses the usual shape of this section: the Jakarta APIs with scope provided, the JSTL
implementation in the WAR, and Java 21. The page directory is the web root.
| File | Request | Concept |
|---|---|---|
|
|
a servlet that puts the books in the request and forwards to the view: tested with mocks |
|
|
the page under test, read in its generated form |
|
used in unit tests only |
a |
|
|
diagnostic pages: dump the scopes, an EL typo, a silent unknown variable and its strict twin |
|
none |
starts the webapp in an embedded Tomcat on a free port |
|
none |
the browser-level and Spring tests |
The test dependencies, all with scope test, are the ones used throughout the page. The versions are the verified ones of
2026-10-08.
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.23.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-jasper</artifactId>
<version>11.0.26</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.htmlunit</groupId>
<artifactId>htmlunit</artifactId>
<version>5.5.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.63.0</version>
<scope>test</scope>
</dependency>
The project also declares tomcat-embed-core 11.0.26, spring-webmvc and spring-test 7.0.9 and org.hamcrest:hamcrest
3.0 with the same scope, and the Surefire plugin 3.5.4.
Reading the Generated Servlet
When a page misbehaves in a way its message cannot explain, the generated Java source is the ground truth. Jasper writes it
into the scratch directory of the web application, and by default it keeps it there: the keepgenerated parameter is
named like an opt-in but defaults to true.
| Runtime | Directory (observed) |
|---|---|
Standalone Tomcat 11, context |
|
Embedded Tomcat, base directory |
|
A |
that directory instead of the default |
The directory names are mechanical. The package is org.apache.jsp followed by the path of the page, and every character that
is not a valid Java identifier part is escaped: a hyphen becomes _002d, so WEB-INF becomes WEB_002dINF, and the
class name is the file name with _jsp appended (books_jsp). The same escaping, with _005f for an underscore, produces
the long field names of tag handler pools. The file starts with a banner carrying the Tomcat version and the generation time,
and its modification time is set to that of the JSP, which is how Jasper decides later whether the page changed.
The following is the part of books_jsp.java that corresponds to the Bookshelf page (the _jspService body and the tag
method for the loop, with the pool bookkeeping left out). The generated file has 240 lines for a 12-line page.
response.setContentType("text/html; charset=UTF-8");
pageContext = _jspxFactory.getPageContext(this, request, response,
null, true, 8192, true);
...
out.write("\n");
out.write("\n");
out.write("<!DOCTYPE html>\n");
out.write("<html>\n");
out.write("<body>\n");
out.write("<h1>Bookshelf</h1>\n");
out.write("<ul>\n");
if (_jspx_meth_c_005fforEach_005f0(_jspx_page_context))
return;
out.write("\n");
out.write("</ul>\n");
out.write("</body>\n");
out.write("</html>\n");
int _jspx_eval_c_005fforEach_005f0 = _jspx_th_c_005fforEach_005f0.doStartTag();
if (_jspx_eval_c_005fforEach_005f0 != jakarta.servlet.jsp.tagext.Tag.SKIP_BODY) {
do {
out.write("\n");
out.write(" <li>");
out.write((java.lang.String) org.apache.jasper.runtime.PageContextImpl.proprietaryEvaluate("${b.title}", java.lang.String.class, (jakarta.servlet.jsp.PageContext)_jspx_page_context, null));
out.write(" by ");
if (_jspx_meth_c_005fout_005f0(_jspx_th_c_005fforEach_005f0, _jspx_page_context, _jspx_push_body_count_c_005fforEach_005f0))
return true;
out.write(' ');
out.write('(');
out.write((java.lang.String) org.apache.jasper.runtime.PageContextImpl.proprietaryEvaluate("${b.year}", java.lang.String.class, (jakarta.servlet.jsp.PageContext)_jspx_page_context, null));
out.write(")</li>\n");
int evalDoAfterBody = _jspx_th_c_005fforEach_005f0.doAfterBody();
if (evalDoAfterBody != jakarta.servlet.jsp.tagext.BodyTag.EVAL_BODY_AGAIN)
break;
} while (true);
}
Reading it is a matter of knowing which construct produces which statement:
| In the page | In books_jsp.java |
|---|---|
Template text, line by line |
One |
|
|
|
a call to the generated method |
|
a method that calls |
|
no statements: directives configure the translation. |
A scriptlet or expression |
its Java text, copied into |
The dependencies of the page |
|
Two practical lessons follow from the generated source. First, a page that "does nothing" often turns out to have written its
output before a failure: look for the first out.write after the failing statement. Second, when a tag does not behave, the
set... calls before doStartTag show exactly which attributes Jasper passed and whether a value was evaluated as an
expression (reqTime = true in the comment above the call) or taken literally. The page on
the generated servlet walks through the whole
structure; this page is about using it to find bugs.
SMAP: Mapping Page Lines to Generated Lines
An error inside books_jsp.java is reported against line 178 of a file the author never wrote. The Pages specification
solves this in section 11.5: a JSP 4.0 container must support the Jakarta Debugging Support for Other Languages
specification, so the compiler produces class files with a SourceDebugExtension attribute that maps each line of the page
to the generated Java lines. For both pages and tag files, written in standard or XML syntax, the stratum that maps to the
original source is named JSP and is the default one. How to switch it on is left to the implementation; on Tomcat it is
on unless suppressSmap is true.
The effect is visible in the error page for a mistyped property. /debug/typo loops over the books and prints
${b.titel}:
<%@ page contentType="text/html; charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<ul>
<c:forEach var="b" items="${books}">
<li>${b.titel}</li>
</c:forEach>
</ul>
HTTP Status 500 – Internal Server Error
Message An exception occurred processing [/WEB-INF/views/debug/typo.jsp] at line [5]
Exception
2: <%@ taglib prefix="c" uri="jakarta.tags.core" %>
3: <ul>
4: <c:forEach var="b" items="${books}">
5: <li>${b.titel}</li>
6: </c:forEach>
7: </ul>
Root Cause
jakarta.servlet.ServletException: jakarta.el.PropertyNotFoundException: Property [titel] not found on type [org.example.bookshelf.Book]
Root Cause
java.lang.NoSuchMethodException: org.example.bookshelf.Book.titel()
jakarta.el.RecordELResolver.getValue(RecordELResolver.java:76)
Jasper says line 5 and prints the page’s own lines, because the stack frame of the generated class was translated through the
SMAP (the generated expression is on line 178 of typo_jsp.java). The Book record is resolved by RecordELResolver, and
its message is the one to read: NoSuchMethodException: Book.titel() is the record accessor that does not exist. The table
itself can be seen with javap -v on the class file in the work directory:
SourceDebugExtension:
SMAP
typo_jsp.java
JSP
*S JSP
*F
+ 0 typo.jsp
WEB-INF/views/debug/typo.jsp
*L
1,4:131
4:162,15
5:177,3
6:180
6,3:136
*E
Each *L entry reads "input lines a,b map to output lines starting at n`", and the optional second number is a repeat
count. `5:177,3 says that JSP line 5 is spread over generated lines 177 to 179 (the evaluation of ${b.titel} is on 178,
inside that range), and 4:162,15 says that the c:forEach on line 4 is the whole generated method for the tag (15 lines).
A debugger uses the same table in the other direction: a breakpoint on line 5 of the page becomes a breakpoint on those Java
lines, and stepping advances through page lines, not generated ones. The specification adds non-normative guidelines (11.5.1)
for good mappings: a breakpoint must stop before the code that translates the line, two JSP lines may share generated lines,
and comments and directives are not mapped.
The same behaviour can be switched off. Declaring the jsp servlet in the web.xml of the application, with the init
parameters suppressSmap=true and displaySourceFragment=false, produced the next result for the same page. There is no
SourceDebugExtension in the class (javap -v finds none) and the error loses the line and the fragment, which is the
reason to leave both on while developing:
<servlet>
<servlet-name>jsp</servlet-name>
<servlet-class>org.apache.jasper.servlet.JspServlet</servlet-class>
<init-param>
<param-name>suppressSmap</param-name>
<param-value>true</param-value>
</init-param>
<init-param>
<param-name>displaySourceFragment</param-name>
<param-value>false</param-value>
</init-param>
<load-on-startup>3</load-on-startup>
</servlet>
Message jakarta.servlet.ServletException: jakarta.el.PropertyNotFoundException: Property [titel] not found on type [org.example.bookshelf.Book]
Exception
org.apache.jasper.JasperException: jakarta.servlet.ServletException: jakarta.el.PropertyNotFoundException: ...
org.apache.jasper.servlet.JspServletWrapper.handleJspException(JspServletWrapper.java:629)
The Jasper Parameters That Help
Jasper is configured with init parameters of the jsp servlet, which the global conf/web.xml declares and which an
application can redeclare in its own web.xml, as the SMAP example did (verified). Every page of the application is then
translated with those settings. The ones that matter for debugging, with the defaults of the Tomcat 11 documentation:
| Parameter | Default | Effect on debugging |
|---|---|---|
|
|
Keeps the generated |
|
|
Emits one |
|
|
|
|
|
|
|
|
Compiles the class with debug information (local variable names for debuggers) |
|
|
Includes the page fragment around the failing line in the message |
|
|
Pages are re-checked for changes, at the interval of |
|
application |
Where the generated source and classes go: point it somewhere you can open in the IDE |
|
|
Whitespace-only template text can be removed; useful to read output, but it changes the output |
|
|
Text is written as char arrays: a size optimisation, not a debugging one |
|
|
Encoding of the generated source: relevant when a page has non-ASCII text and the source looks garbled |
|
|
Java level the generated class targets |
The complete table and the production values belong to
Jasper Configuration & Precompilation; the rows above are only those that change what a developer
sees. A parameter set on a servlet that is not named jsp does nothing, and a change to a parameter applies to pages
translated after the application restarts, not to a class already compiled.
Finding the Bug: Errors, Silent Failures and Scope Dumps
Reading the error report
A failure in a page produces the Tomcat error report with three useful parts, in this order: the message with the page and
line (An exception occurred processing [/WEB-INF/views/debug/typo.jsp] at line [5]), the fragment of the page around that
line, and the chain of root causes. The first cause is usually a wrapper (ServletException, JasperException) and the last
one is the actual error; the stack trace of the last one is in the server log, which the report says in its closing note.
Compilation errors (a mistake in a scriptlet or an unknown tag) arrive in the same form on the first request for the page,
because translation is lazy; JSP Lifecycle and Translation shows both kinds. The error page of the application, if one is configured,
replaces this report, and is the reason a production system can hide the very information a developer needs: reproduce an
error in a development profile in which no custom error page is declared.
Unknown variables fail silently by default
An EL expression whose first name is not in any scope is not an error: the NotFoundELResolver returns null, and null
prints as an empty string. A typo in a variable name therefore produces an empty field and no message. Two pages differ in
nothing but their path. /debug/lenient and /debug/strict both contain Subtitle: [${book.subtitle}], but the second
one is covered by a property group in web.xml:
<jsp-config>
<jsp-property-group>
<url-pattern>/WEB-INF/views/debug/strict.jsp</url-pattern>
<error-on-el-not-found>true</error-on-el-not-found>
</jsp-property-group>
</jsp-config>
$ curl -s http://localhost:18080/g7testing/debug/lenient
<p>Subtitle: []</p>
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:18080/g7testing/debug/strict
500
...
Root Cause
jakarta.el.PropertyNotFoundException: Unknown identifier [book]
jakarta.servlet.jsp.el.NotFoundELResolver.getValue(NotFoundELResolver.java:65)
The same option exists per page as the errorOnELNotFound attribute of the page directive. Turning it on for the whole
application during development (a property group for *.jsp, as the specification’s own example does) and in the test suite
turns every misspelt variable into a test failure instead of an empty field. It does not cover a property that does not
exist on an object that does, which already fails with PropertyNotFoundException, as the titel example showed.
Dump what the page can see
When a value is missing, the first question is which scope it is in, or whether it is there at all. A diagnostic page that
lists a scope costs three lines and answers it. /debug/scopes lists the request attributes, with c:out so that a value
containing markup is shown and not interpreted:
<%@ page contentType="text/plain; charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
request attributes:
<c:forEach var="e" items="${requestScope}">
${e.key} = <c:out value="${e.value}"/>
</c:forEach>
request URI: <c:out value="${pageContext.request.requestURI}"/>
context path: <c:out value="${pageContext.request.contextPath}"/>
request attributes:
jakarta.servlet.forward.path_info = /scopes
books = [Book[id=1, title=Dune, author=Frank Herbert, year=1965], Book[id=2, title=Neuromancer, author=William Gibson, year=1984]]
jakarta.servlet.forward.request_uri = /g7testing/debug/scopes
jakarta.servlet.forward.servlet_path = /debug
jakarta.servlet.forward.mapping = MappingImpl[matchValue=scopes, pattern=/debug/*, mappingType=PATH, servletName=org.example.bookshelf.DebugServlet]
jakarta.servlet.forward.context_path = /g7testing
request URI: /g7testing/WEB-INF/views/debug/scopes.jsp
context path: /g7testing
The output also shows what a forward does to the request: the original request is described by the jakarta.servlet.forward.*
attributes, while getRequestURI() now names the view, a source of confusion when a page builds a link from it. The
pageScope, sessionScope and applicationScope maps work the same way. A page like this must exist only in development
(its dispatcher servlet here accepts a fixed set of names and answers 404 to anything else: /debug/nope returned 404),
because a scope dump is a leak of everything stored in the session.
Turn on Jasper’s own log
Jasper logs through java.util.logging, in the logger org.apache.jasper. The default level is INFO; at FINE it reports
how the pages and tag libraries were found. A logging.properties for a test run, with the console handler opened to FINE:
handlers = java.util.logging.ConsoleHandler
.level = INFO
java.util.logging.ConsoleHandler.level = FINE
java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter
org.apache.jasper.level = FINE
Passed to the forked test JVM with -DargLine="-Djava.util.logging.config.file=src/test/resources/jasper-debug.properties",
the embedded run printed lines such as these, which explain why a taglib URI is or is not found:
FINE: Initializing Jasper for context [null]
FINE: No TLD files were found in resource path [/WEB-INF/views/].
FINE: TLD files were found in JAR [file:/.../org/glassfish/web/jakarta.servlet.jsp.jstl/3.0.1/jakarta.servlet.jsp.jstl-3.0.1.jar].
In a standalone Tomcat the same logger is configured in conf/logging.properties, and the output goes to the catalina
file and the console. Raising it to FINE for every request is noisy; do it for a reproduction and put it back.
Attaching a Debugger
Tomcat starts in debug mode through the jpda argument of catalina.sh. In the 11.0.26 script, jpda start adds
-agentlib:jdwp=transport=dt_socket,address=localhost:8000,server=y,suspend=n to CATALINA_OPTS; the transport, address and
suspend flag come from JPDA_TRANSPORT (default dt_socket), JPDA_ADDRESS (default localhost:8000) and JPDA_SUSPEND
(default n), and JPDA_OPTS replaces all of them. From the IDE, a remote debug configuration to that port is attached
to the running JVM:
export JPDA_ADDRESS=localhost:8000
$CATALINA_HOME/bin/catalina.sh jpda start
# set JPDA_SUSPEND=y to hold the JVM until the debugger attaches (to debug startup)
A breakpoint set on a line of a .jsp file is resolved by the SMAP of the class, so no generated source is needed in the
IDE; the IDE finds the page by the file name in the SMAP (WEB-INF/views/debug/typo.jsp above), which is why the IDE
must have the web application’s sources or module configured, and why a page that the server compiled before the IDE
attached still works (the class is already loaded with its table). If breakpoints in pages are ignored, the first things
to check are suppressSmap and that the running class corresponds to the saved page, because a page changed on disk but not
yet recompiled maps to the old lines.
The same agent works for tests. A Maven build can start the forked test JVM with a debug agent without changing the pom, through
the argLine property, which is how the example below was checked (the build printed
Listening for transport dt_socket at address: 5005 and ran RecentTagTest to completion); Surefire’s own
-Dmaven.surefire.debug property is the short form that suspends until the IDE attaches.
mvn test -Dtest=RecentTagTest -DargLine="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
For a debugger to reach a container the address must be reachable. Since JDK 9 an address of just a port listens on the loopback
interface only (observed on JDK 21: 127.0.0.1:5006), so a JVM in a container needs address=*:5005 and a published port. A JDWP port is an unauthenticated
remote code execution interface: bind it to the loopback or a private network, and never leave an agent configured in a
production start script.
A Test Strategy for Pages
A JSP application has four places where a defect can live, and each one has a test that is cheap for it and a test that is not. The cheap tests find most defects; the browser tests exist because the last mile (the rendered page) is what the user sees and only they test it.
servlet with mocks,
tag handler with a mocked JspContext"] --> render["Rendered-view tests
embedded Tomcat, real Jasper,
HTTP client or HtmlUnit"] render --> browser["Browser tests
Playwright: real engine,
JavaScript, layout, accessibility"] spring["Spring variant
MockMvc: model and selected view,
the JSP is not rendered"] -.-> render unit -.->|"milliseconds"| fast["Run on every save"] render -.->|"seconds"| mid["Run on every build"] browser -.->|"many seconds"| slow["Run per pull request"]
| Layer | Proves | Does not prove |
|---|---|---|
Servlet or action with mocks (JUnit, Mockito) |
The controller sets the right request attributes, chooses the view and handles bad input |
That the page renders, that the view name exists, that the mapping is declared |
Tag handler with a mocked |
The handler writes the right output for given attributes |
That the TLD declares the tag and attribute types, or that a page can use it |
Rendered view in an embedded Tomcat |
Jasper can translate and compile the page, the EL resolves, the tag libraries are found, the response has the expected status and body |
JavaScript behaviour, CSS and layout, browser quirks |
HtmlUnit |
The rendered HTML parses and has the expected structure; simple forms and links work; a fast headless client without a browser binary |
Modern browser behaviour (HtmlUnit is a simulated browser) |
Playwright (or Selenium, Cypress) |
The page works in a real browser engine, including JavaScript, navigation and accessibility checks |
It is the slowest layer and needs browser binaries |
Spring MockMvc |
A controller selects the view and fills the model, with the Spring pipeline (binding, validation, converters) in place |
That the JSP renders: MockMvc does not run Jasper |
The rendered-view layer is the one specific to JSP, and is the one most projects skip: a compile error in a page, a missing
tag library or an EL typo with errorOnELNotFound on, are only found when Jasper has translated the page, which otherwise
happens on the first request of production.
Source: JUnit User Guide
Unit-Testing a Servlet with Mocks
A servlet that forwards to a view has a simple contract: it puts a model in the request and forwards. The Jakarta Servlet API is
made of interfaces (HttpServletRequest, HttpServletResponse, RequestDispatcher), so Mockito can replace the container.
Giving the servlet a constructor that takes its collaborator (here a BookRepository), next to the public no-argument
constructor the container needs, keeps it testable without a server.
@ExtendWith(MockitoExtension.class)
class BooksServletTest {
@Mock HttpServletRequest request;
@Mock HttpServletResponse response;
@Mock RequestDispatcher dispatcher;
@Test
void putsBooksInRequestScopeAndForwardsToTheView() throws Exception {
List<Book> books = List.of(new Book(1, "Dune", "Frank Herbert", 1965));
BooksServlet servlet = new BooksServlet(() -> books);
when(request.getRequestDispatcher("/WEB-INF/views/books.jsp")).thenReturn(dispatcher);
servlet.doGet(request, response);
ArgumentCaptor<Object> model = ArgumentCaptor.forClass(Object.class);
verify(request).setAttribute(eq("books"), model.capture());
assertEquals(books, model.getValue());
verify(dispatcher).forward(request, response);
}
}
The servlet’s doGet is protected, which is why the test sits in the same package as the servlet. The test checks three
facts that are all part of the controller’s contract: the attribute name the page will use (books), its value, and the exact
view path. Spring’s MockHttpServletRequest and MockHttpServletResponse (in spring-test) are an alternative to Mockito
that is a real implementation of the interfaces and is useful when the code reads parameters or headers; a servlet
that only sets attributes is faster to write with mocks.
Source: Mockito
Source: JUnit User Guide
Unit-Testing a Tag Handler
A classic or simple tag handler is a Java class, and the container’s part of its contract (the JspContext, the JspWriter)
is again made of types that can be replaced. The Bookshelf’s RecentTag marks books published since a given year:
public class RecentTag extends SimpleTagSupport {
private int year;
private int since = 2000;
public void setYear(int year) {
this.year = year;
}
public void setSince(int since) {
this.since = since;
}
@Override
public void doTag() throws JspException, IOException {
if (year >= since) {
getJspContext().getOut().write("<span class=\"new\">new</span>");
}
}
}
class RecentTagTest {
private String render(int year) throws Exception {
StringWriter sink = new StringWriter();
JspWriter out = Mockito.mock(JspWriter.class);
Mockito.doAnswer(call -> {
sink.write(call.<String>getArgument(0));
return null;
}).when(out).write(Mockito.anyString());
JspContext context = Mockito.mock(JspContext.class);
Mockito.when(context.getOut()).thenReturn(out);
RecentTag tag = new RecentTag();
tag.setJspContext(context);
tag.setYear(year);
tag.doTag();
return sink.toString();
}
@Test
void marksRecentBooks() throws Exception {
assertEquals("<span class=\"new\">new</span>", render(2021));
}
@Test
void leavesOldBooksUnmarked() throws Exception {
assertEquals("", render(1965));
}
}
The container calls setJspContext before doTag, and the test does the same; the mocked JspWriter copies what is written
into a StringWriter. A handler that uses the body (getJspBody().invoke(...)) can be tested the same way with a mocked
JspFragment. What this does not test is the declaration: the tag library descriptor, the attribute types and the body-content
are exercised only when a page uses the tag, which is the next layer (Tag Library Descriptors & EL Functions
lists what the descriptor must say).
Rendering the Real Page in an Embedded Tomcat
The strongest JSP-specific test starts the application in an embedded Tomcat, so that the same Jasper that production uses
translates, compiles and runs the page. The embedded Tomcat uses the Tomcat artifacts as ordinary dependencies (tomcat-embed-core
and tomcat-embed-jasper, here at 11.0.26, the same as the server) and is controlled by the Tomcat class. The helper starts
the webapp on port 0, so the operating system picks a free port and tests can run in parallel with a server on 8080:
final class EmbeddedBookshelf implements AutoCloseable {
private final Tomcat tomcat = new Tomcat();
EmbeddedBookshelf(File baseDir) throws Exception {
tomcat.setBaseDir(baseDir.getAbsolutePath());
tomcat.setPort(0);
tomcat.getConnector();
Context context = tomcat.addWebapp("", new File("src/main/webapp").getAbsolutePath());
((StandardJarScanner) context.getJarScanner()).setScanManifest(false);
WebResourceRoot resources = new StandardRoot(context);
resources.addPreResources(new DirResourceSet(
resources, "/WEB-INF/classes", new File("target/classes").getAbsolutePath(), "/"));
context.setResources(resources);
// Jasper options are init parameters of the "jsp" servlet declared by Tomcat's default web.xml.
context.addLifecycleListener((LifecycleEvent event) -> {
if (Lifecycle.CONFIGURE_START_EVENT.equals(event.getType())) {
Wrapper jsp = (Wrapper) context.findChild("jsp");
jsp.addInitParameter("keepgenerated", "true");
jsp.addInitParameter("development", "true");
}
});
tomcat.start();
}
int port() { return tomcat.getConnector().getLocalPort(); }
String url(String path) { return "http://localhost:" + port() + path; }
@Override
public void close() throws Exception {
tomcat.stop();
tomcat.destroy();
}
}
Four details of this helper are the ones that otherwise cost an afternoon. The document base is the source directory
src/main/webapp, so the page is the one in the repository and not a copy; the compiled classes are mounted as a
pre-resource at /WEB-INF/classes, because the webapp directory has no classes and @WebServlet annotations are found by
scanning them; the Jasper options are applied to the jsp wrapper once the default web.xml configuration has created it
(the CONFIGURE_START_EVENT), because a wrapper that does not exist cannot be configured earlier; and the JAR scanner has its
manifest scanning turned off. The last one answers a real warning: with HtmlUnit 5.5.0 on the classpath, a dependency’s
manifest lists jars (jetty-client-12.1.12.jar and four others) that are not on disk, and without setScanManifest(false)
Tomcat logs WARNING: Failed to scan [file:/.../htmlunit-websocket-client/5.5.0/jetty-client-12.1.12.jar] with a stack trace for
each of them, while the tests still pass.
The test then makes real HTTP requests with the JDK’s HttpClient and asserts on the response, and, because Jasper keeps the
generated source, can also prove it exists:
class RenderedPageTest {
static EmbeddedBookshelf app;
@BeforeAll
static void start() throws Exception {
app = new EmbeddedBookshelf(new File("target/tomcat-base"));
}
@AfterAll
static void stop() throws Exception {
app.close();
}
@Test
void booksPageListsTheBooks() throws Exception {
HttpResponse<String> response = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create(app.url("/books"))).build(),
HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode());
assertTrue(response.headers().firstValue("Content-Type").orElse("").startsWith("text/html"));
assertTrue(response.body().contains("<li>Dune by Frank Herbert (1965)</li>"), response.body());
}
@Test
void jasperKeepsTheGeneratedSource() throws Exception {
booksPageListsTheBooks();
try (Stream<Path> files = Files.walk(Path.of("target/tomcat-base/work"))) {
Path generated = files.filter(p -> p.getFileName().toString().equals("books_jsp.java"))
.findFirst().orElseThrow();
assertTrue(Files.readString(generated).contains("class books_jsp"));
}
}
}
The run printed GENERATED: target/tomcat-base/work/Tomcat/localhost/ROOT/org/apache/jsp/WEB_002dINF/views/books_jsp.java,
the path listed in the first table of this page. A test of this kind fails the build for a page that does not compile and for
one that returns an error status, which is the build-time check that a precompilation with JspC provides by another way
(see Jasper Configuration & Precompilation). Making the failure readable matters: passing
response.body() as the assertion message, as above, puts the Tomcat error report, with its page line and root cause, in the
test report.
HtmlUnit: Asserting on the DOM Without a Browser
An HTTP client and string contains is fragile: a change in whitespace or attribute order breaks the assertion without
changing the page. HtmlUnit is a Java library that behaves like a browser (it fetches the page, parses the HTML into a DOM
and runs JavaScript with its own engine) with no browser binary, which makes it fast and suitable for the same build as the
unit tests. The 5.5.0 release lives in the package org.htmlunit, not the old com.gargoylesoftware.htmlunit of the books'
era.
class BooksPageHtmlUnitTest {
static EmbeddedBookshelf app;
@BeforeAll
static void start() throws Exception {
app = new EmbeddedBookshelf(new File("target/tomcat-base-hu"));
}
@AfterAll
static void stop() throws Exception {
app.close();
}
@Test
void rendersOneItemPerBook() throws Exception {
try (WebClient client = new WebClient()) {
client.getOptions().setThrowExceptionOnFailingStatusCode(true);
HtmlPage page = client.getPage(app.url("/books"));
assertEquals("Bookshelf", page.getFirstByXPath("//h1/text()").toString());
List<DomElement> items = page.getByXPath("//ul/li");
assertEquals(2, items.size());
assertEquals("Neuromancer by William Gibson (1984)", items.get(1).asNormalizedText());
}
}
}
Each test class gets its own base directory (target/tomcat-base-hu) so the work directories of two embedded servers do not
collide. The test selects with XPath, counts the items and reads a normalised text, and the setting
setThrowExceptionOnFailingStatusCode(true) makes a 404 or 500 fail the test with the status instead of returning an
error page that the assertions then fail on in a confusing way. HtmlUnit is also the library to test a form: it can find a
form, set its fields and submit it, which suits the scenario of Forms, Validation & Sessions. Its limit is that it is
a simulation, with its own JavaScript engine and a CSS model that does not lay out a page.
Source: HtmlUnit — Getting Started
Playwright: the Page in a Real Browser
For what only a real engine shows (layout, scripts that need a modern browser, accessibility checks, a screenshot), Playwright for Java drives Chromium, Firefox or WebKit. The test reuses the embedded server and asserts with auto-waiting locators, which removes the sleeps that make browser tests flaky:
class BooksPagePlaywrightTest {
static EmbeddedBookshelf app;
@BeforeAll
static void start() throws Exception {
app = new EmbeddedBookshelf(new File("target/tomcat-base-pw"));
}
@AfterAll
static void stop() throws Exception {
app.close();
}
@Test
void showsTheBooksInARealBrowser() {
try (Playwright playwright = Playwright.create();
Browser browser = playwright.chromium().launch()) {
Page page = browser.newPage();
page.navigate(app.url("/books"));
assertThat(page.locator("h1")).hasText("Bookshelf");
assertThat(page.locator("ul > li")).hasCount(2);
assertThat(page.locator("ul > li").first()).hasText("Dune by Frank Herbert (1965)");
}
}
}
The test passed on this machine, which already had the Playwright 1.63 browsers installed (the run set
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 so that nothing was downloaded). On a clean machine or a CI runner the browser binaries
have to be installed first, once, with the Playwright command line (mvn exec:java -e -D exec.mainClass=com.microsoft.playwright.CLI
-D exec.args="install" in the documentation), which is why the usual practice is to run these tests in their own
Maven profile or in a separate job. The tests of this layer should be few and cover journeys (search, add, save), not every
page; the unit and rendered-view layers cover the pages. The tool comparison, tracing, parallel execution and CI
recipes live on End-to-End Testing with Real Browsers, and issue #258 covers the
three tools in depth.
Source: Playwright for Java — Assertions
Source: Playwright for Java — Browsers
The Spring Variant: MockMvc Selects the View, It Does Not Render It
A Spring MVC application that uses JSP views (see Spring MVC with JSP) tests its controllers with
MockMvc. A standalone set-up with an InternalResourceViewResolver, as in the application, is enough, and it shows an
important limit:
class BooksControllerMockMvcTest {
@Controller
static class BooksController {
@GetMapping("/books")
String list(Model model) {
model.addAttribute("books", List.of(new Book(1, "Dune", "Frank Herbert", 1965)));
return "books";
}
}
MockMvc mvc = MockMvcBuilders.standaloneSetup(new BooksController())
.setViewResolvers(new InternalResourceViewResolver("/WEB-INF/views/", ".jsp"))
.build();
@Test
void selectsTheViewButDoesNotRenderIt() throws Exception {
mvc.perform(get("/books"))
.andExpect(status().isOk())
.andExpect(model().attributeExists("books"))
.andExpect(forwardedUrl("/WEB-INF/views/books.jsp"))
.andExpect(content().string(""));
}
}
The test passes with content().string(""): the response is empty, because the view is an InternalResourceView that
forwards to /WEB-INF/views/books.jsp, and MockMvc records the forward (forwardedUrl) without invoking a JSP engine. The
assertions available are therefore the status, the model, the view name and the forwarded URL. A test that wants the HTML of a
JSP goes through the embedded-Tomcat layer above, or through a Spring Boot test with a real servlet container and
TestRestTemplate or a Playwright test. (With Thymeleaf or FreeMarker the view is rendered by the template engine inside
the mock environment, which is one of the reasons those engines are easier to test than JSP.) A small build detail: the
MockMvc matchers are Hamcrest-based, and spring-test does not bring Hamcrest, so without org.hamcrest:hamcrest on the test
classpath the compile fails with cannot access org.hamcrest.Matcher.
The general approach to testing Spring applications is covered by Unit and Integration Testing in Spring Boot, and the Java testing tools by Testing in Java.
The Whole Run
The suite is one mvn test (mvn -B package also builds the WAR). The result of the run on 2026-10-08, with Tomcat embedded
and the browser binaries already installed:
| Test class | Tests | What it exercises |
|---|---|---|
|
1 |
servlet with Mockito mocks |
|
2 |
tag handler with a mocked |
|
2 |
embedded Tomcat, |
|
1 |
embedded Tomcat, HtmlUnit DOM assertions |
|
1 |
embedded Tomcat, headless Chromium |
|
1 |
Spring MockMvc, forwarded view |
Total |
8 |
0 failures, 0 errors, 0 skipped |
The single-class and fast layers can be run alone with -Dtest=... (as in the debugger example), which is the loop to use
while writing a page; the browser layer is for the build.
Pitfalls and Symptoms
| Symptom | Cause | Fix |
|---|---|---|
An empty field and no error when a variable is misspelt (observed: |
Unknown EL identifiers resolve to |
|
|
The property does not exist on the bean or record |
Read the root cause, which names the missing accessor; fix the name |
Stack trace points at |
The first frames are generated lines |
Read Jasper’s "at line [5]" and the fragment; use the SMAP table ( |
No page line number in errors, breakpoints in a JSP are ignored (observed with |
The class has no |
Remove |
A Jasper parameter has no effect |
It was set on a servlet that is not the |
Declare the servlet as |
Edits to a page are not seen (production-like settings) |
|
Use development settings locally; in production redeploy |
The generated |
The scratch directory differs ( |
Search the base directory for |
Output is empty but the tests pass with MockMvc (observed) |
MockMvc forwards to the JSP without rendering it |
Test the rendering in an embedded container |
|
The compiled classes are not in |
Add a |
|
A dependency’s manifest |
|
|
|
Add |
The test passes for the servlet and fails in the browser |
Mocks prove the controller, not the view or the mapping |
Add a rendered-view test per page; keep browser tests for journeys |
Playwright fails to launch the browser on a new machine |
The browser binaries are not installed |
Run the Playwright installer once, or cache it in the CI job |
Two embedded servers fail or interfere |
The same base directory or fixed port |
Port |
A scope dump page is reachable in production |
A diagnostic page was left in the application |
Keep such pages out of the production WAR or behind an allow-list and authentication |
A debugger port is open to the network |
|
Bind to loopback or a private interface, never in production start scripts |
What Changed Recently
| Release | Change |
|---|---|
Pages 4.0 (Jakarta EE 11) |
Section 11.5 still requires containers to support the Jakarta Debugging Support for Other Languages specification (SMAP, stratum |
Pages 3.1 (Jakarta EE 10) |
|
Tomcat 11.0.x |
Jasper |
JUnit 6 |
The Jupiter artifact |
HtmlUnit 5.x |
The package is |
Spring Framework 7 |
|
Where the Books Are Outdated
Web Development with JavaServer Pages (Manning, 2001, listed in the section bibliography) describes JSP 1.2 on Tomcat 4. Its container chapter introduces the debugging requirements and the generated servlet that this page builds on. The concepts are unchanged: a page is a servlet, the container keeps the source, and a line table maps it back. The tools around them are not.
| The book’s era | Today |
|---|---|
Debugging by writing to |
Use a debugger with the SMAP, the Jasper and application logs, or a scope-dump page in development; scriptlets are legacy |
Reading the generated source because errors only showed generated line numbers |
Messages show page lines and a fragment ( |
The debugging specification as a JSR (JSR 45) |
Now Jakarta Debugging Support for Other Languages 2.0, required by Pages 4.0 section 11.5 |
|
|
Testing needs a running container or an in-container test framework deployed in it |
Servlets and handlers are plain Java with Mockito; the container is embedded in the test ( |
JUnit 3 style tests ( |
JUnit Jupiter 6 with |
Browser simulation with the HtmlUnit and HttpUnit of the day, |
|
Unknown variables in |
|
Remote debugging with |
|
References
-
Jakarta Pages 4.0 — Appendix C.1, Changes between JSP 4.0 and JSP 3.1
-
Jakarta Pages 4.0 — Appendix C.2, Changes between JSP 3.1 and JSP 3.0
-
Apache Tomcat 11 — Jasper 2 JSP Engine How-To, Configuration
-
Apache Tomcat 11 — The Jar Scanner Component, Standard Implementation
-
Java Platform Debugger Architecture — Connection and Invocation Details
-
Manning — Web Development with JavaServer Pages, Second Edition