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.

Table 1. The files of the example project
File Request Concept

src/main/java/.../BooksServlet.java

/books

a servlet that puts the books in the request and forwards to the view: tested with mocks

src/main/webapp/WEB-INF/views/books.jsp

/books (through the servlet)

the page under test, read in its generated form

src/main/java/.../RecentTag.java

used in unit tests only

a SimpleTagSupport handler that marks recent books: tested with a mocked JspContext

src/main/java/.../DebugServlet.java and WEB-INF/views/debug/*.jsp

/debug/scopes, /debug/typo, /debug/lenient, /debug/strict

diagnostic pages: dump the scopes, an EL typo, a silent unknown variable and its strict twin

src/test/java/.../EmbeddedBookshelf.java

none

starts the webapp in an embedded Tomcat on a free port

RenderedPageTest, BooksPageHtmlUnitTest, BooksPagePlaywrightTest, BooksControllerMockMvcTest

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.

Table 2. Where the generated source of /WEB-INF/views/books.jsp is written
Runtime Directory (observed)

Standalone Tomcat 11, context g7testing

$CATALINA_BASE/work/Catalina/localhost/g7testing/org/apache/jsp/WEB_002dINF/views/books_jsp.java, next to books_jsp.class

Embedded Tomcat, base directory target/tomcat-base, context path ""

target/tomcat-base/work/Tomcat/localhost/ROOT/org/apache/jsp/WEB_002dINF/views/books_jsp.java

A scratchdir init parameter on the jsp servlet

that directory instead of the default work directory of the application

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:

Table 3. What each part of the page became in the generated source
In the page In books_jsp.java

Template text, line by line

One out.write("...") per input line, because mappedfile is true. The line structure of the page is preserved so that the SMAP can map it

${b.title} in template text

proprietaryEvaluate("${b.title}", String.class, ...) written straight to out: there is no escaping, a point the Security page returns to

<c:out value="${b.author}"/>

a call to the generated method _jspx_meth_c_005fout_005f0, which gets a pooled OutTag, sets value (evaluated with proprietaryEvaluate) and calls doStartTag/doEndTag. The tag does the escaping

<c:forEach var="b" items="${books}">

a method that calls setItems with a JspValueExpression and runs the doStartTag/doAfterBody loop, with the EVAL_BODY_AGAIN test that is the iteration

<%@ page ... %> and <%@ taglib ... %>

no statements: directives configure the translation. contentType becomes response.setContentType; the page buffer and session appear as the getPageContext(..., null, true, 8192, true) arguments (error page, session, buffer size, autoflush)

A scriptlet or expression

its Java text, copied into _jspService with the line numbers mapped by the SMAP

The dependencies of the page

_jspx_dependants, the map of included files and tag library descriptors with their time stamps, which drives recompilation

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:

Table 4. Jasper parameters used while debugging (Tomcat 11.0 Jasper How-To)
Parameter Default Effect on debugging

keepgenerated

true

Keeps the generated .java next to the class. Set false only to save disk in production

mappedfile

true

Emits one out.write per template line, which keeps line numbers aligned for the SMAP

suppressSmap

false

true removes the SourceDebugExtension: no page line numbers in errors or debuggers

dumpSmap

false

true also writes the SMAP to a file for inspection (ignored when suppressSmap is true)

classdebuginfo

true

Compiles the class with debug information (local variable names for debuggers)

displaySourceFragment

true

Includes the page fragment around the failing line in the message

development

true

Pages are re-checked for changes, at the interval of modificationTestInterval; with false an edit to a deployed page is not seen

scratchdir

application work directory

Where the generated source and classes go: point it somewhere you can open in the IDE

trimSpaces

false

Whitespace-only template text can be removed; useful to read output, but it changes the output

genStringAsCharArray

false

Text is written as char arrays: a size optimisation, not a debugging one

javaEncoding

UTF8

Encoding of the generated source: relevant when a page has non-ASCII text and the source looks garbled

compilerTargetVM

17

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.

flowchart TD unit["Plain unit tests
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"]
Table 5. What each test layer proves and what it cannot
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 JspContext

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.

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

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.

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.

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:

Table 6. Surefire results of the example project
Test class Tests What it exercises

BooksServletTest

1

servlet with Mockito mocks

RecentTagTest

2

tag handler with a mocked JspContext

RenderedPageTest

2

embedded Tomcat, HttpClient, generated source on disk

BooksPageHtmlUnitTest

1

embedded Tomcat, HtmlUnit DOM assertions

BooksPagePlaywrightTest

1

embedded Tomcat, headless Chromium

BooksControllerMockMvcTest

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

Table 7. Testing and debugging pitfalls, their causes and fixes (rows marked "observed" were reproduced on Tomcat 11.0.26)
Symptom Cause Fix

An empty field and no error when a variable is misspelt (observed: Subtitle: [])

Unknown EL identifiers resolve to null by default

error-on-el-not-found in a property group, or the errorOnELNotFound page attribute, in development and in tests

PropertyNotFoundException: Property [titel] not found on type [...Book], with NoSuchMethodException: Book.titel() (observed)

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 xxx_jsp.java:178

The first frames are generated lines

Read Jasper’s "at line [5]" and the fragment; use the SMAP table (javap -v) to map by hand

No page line number in errors, breakpoints in a JSP are ignored (observed with suppressSmap=true)

The class has no SourceDebugExtension

Remove suppressSmap; keep displaySourceFragment on while developing

A Jasper parameter has no effect

It was set on a servlet that is not the jsp servlet, or the page was compiled before the change

Declare the servlet as jsp with org.apache.jasper.servlet.JspServlet, restart the application

Edits to a page are not seen (production-like settings)

development is false

Use development settings locally; in production redeploy

The generated .java is not where expected

The scratch directory differs (scratchdir, embedded base directory, Tomcat work layout)

Search the base directory for *_jsp.java, as the test above does

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

@WebServlet servlet gives 404 in the embedded test

The compiled classes are not in /WEB-INF/classes of the context

Add a DirResourceSet pre-resource for target/classes, as above

WARNING: Failed to scan [file:...jetty-client-12.1.12.jar] with a stack trace per jar (observed)

A dependency’s manifest Class-Path names jars that are missing

StandardJarScanner.setScanManifest(false)

cannot access org.hamcrest.Matcher when compiling a MockMvc test (observed)

spring-test does not include Hamcrest

Add org.hamcrest:hamcrest with scope test

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 0 and a base directory per test class

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

address=*:port was used outside a trusted network

Bind to loopback or a private interface, never in production start scripts

What Changed Recently

Table 8. Changes relevant to testing and debugging, newest first
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 JSP) for pages and tag files in both syntaxes; isThreadSafe was removed, so a test page that declares it fails translation; Java 17 is the minimum

Pages 3.1 (Jakarta EE 10)

error-on-el-not-found and the errorOnELNotFound attribute give strict handling of unknown EL identifiers (section 3.3.4)

Tomcat 11.0.x

Jasper development, keepgenerated and mappedfile default as shown in this page; the jpda option of catalina.sh as quoted; the embedded API (Tomcat.addWebapp, StandardJarScanner) is the one used above

JUnit 6

The Jupiter artifact org.junit.jupiter:junit-jupiter is now at 6.1.3, and requires Java 17 at runtime

HtmlUnit 5.x

The package is org.htmlunit with Maven coordinates org.htmlunit:htmlunit (version 5.5.0 verified); older releases and the books use com.gargoylesoftware.htmlunit

Spring Framework 7

spring-test 7.0.9 with MockMvc verified in this page (standalone set-up, InternalResourceViewResolver); the Hamcrest matchers still need org.hamcrest:hamcrest on the test classpath

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.

Table 9. JSP 1.2-era debugging and testing material and what applies today (Pages 4.0, Tomcat 11)
The book’s era Today

Debugging by writing to System.out or the out object from scriptlets

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 (displaySourceFragment), and the SMAP maps debugger lines; the generated source remains for hard cases

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

javax.servlet.jsp.* and javax.servlet.* in tests and mocks

jakarta.servlet.jsp.* and jakarta.servlet.*: mocks and test servers must be the Jakarta 6.1 generation (Tomcat 11, tomcat-embed-* 11.x)

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 (tomcat-embed-jasper) and started in the test JVM

JUnit 3 style tests (extends TestCase)

JUnit Jupiter 6 with @Test and extensions (@ExtendWith(MockitoExtension.class))

Browser simulation with the HtmlUnit and HttpUnit of the day, com.gargoylesoftware.htmlunit

org.htmlunit 5.5.0 for a headless DOM, and Playwright, Selenium or Cypress for real browsers

Unknown variables in ${} quietly blank, and nothing to detect it

error-on-el-not-found (Pages 3.1 and later) turns it into an error

Remote debugging with jpda or -Xrunjdwp

-agentlib:jdwp=...; the -Xrunjdwp option is the older spelling