Testing Tika Integrations
|
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. |
Baseline date: 2026-10-13. This page was written against Apache Tika 4.1.0 (the latest release on Maven Central on that
date) with the 3.3.x differences noted, Java 21 for the examples (Tika 4.x needs Java 17 or later), Spring Boot 4.x on
Spring Framework 7 (Spring Boot 4.1.1’s dependency management brings JUnit Jupiter 6.0.3, AssertJ 3.27.7 and Testcontainers
2.0.5, the versions used here) and the Docker image apache/tika:4.1.0-1. Code that calls Tika is easy to test badly. A
test that parses one happy PDF and checks that "some text came back" passes on the day it is written and on the day
the PDF parser starts dropping half the catalogue. This page builds the test suite of the Bookshelf intake service
layer by layer: fast unit tests that pin the text and metadata of small committed fixture files, tests for the files
that are broken on purpose, MockParser scripts that make a "document" hang, run out of memory or kill the JVM, golden
snapshots that turn a Tika upgrade into a reviewable diff, and container tests that hold a real tika-server to the
HTTP contract the service depends on. It ends with the checklists the Tika project itself runs before a release, and
how to reuse them.
Related pages: Parser API & AutoDetectParser (what a
parse returns and throws), Metadata (the tk: keys the tests assert),
Embedded Documents & Recursive Parsing (the
recursive list the golden files store),
Robustness, Limits & Timeouts (the full MockParser
reference and the timeout model), Tika Server (REST API) (the
endpoints and status codes the contract checks),
Docker & Kubernetes Deployment (the image),
Spring Boot Integration (the service under test) and
tika-eval & Extraction Quality (regression testing at
corpus scale). Container basics are on
Testcontainers Fundamentals and
Spring Boot Integration Tests with Testcontainers.
Every test on this page was compiled and run with Maven 3.9 on Java 21 on an 8-core macOS machine, against
tika-core, tika-parsers-standard-package, tika-pipes-fork-parser and tika-serialization 4.1.0, the tika-core
4.1.0 tests jar, JUnit Jupiter 6.0.3 (and 5.13.4 before that, with the same result), AssertJ 3.27.7 and
Testcontainers 2.0.5. No Docker daemon was available on the test machine, so the Testcontainers class was compiled and
reported as skipped; the identical HTTP contract was run, and passed, against the tika-server-standard-4.1.0 zip
distribution started locally. The section What was verified and what was not lists the details.
What to test, and at which level
A Tika integration fails in four different ways, and each needs a different kind of test. Mixing them in one class makes the suite slow and the failures hard to read, so the Bookshelf project keeps them apart.
| Layer | Question it answers | Tools | Cost per run |
|---|---|---|---|
Unit tests on fixture files |
Does our code get the right text, metadata, type and embedded documents out of the files we care about? |
JUnit, AssertJ, |
Seconds (the first parse loads the parser registry) |
Malformed and malicious fixtures |
Does an empty, encrypted, truncated, unknown or explosive file end in a defined outcome, never a hang or a silent gap? |
The same, plus generated fixtures in a |
Seconds |
Failure modes |
Does the service survive a parser that hangs, runs out of memory or kills its JVM, and does the next file still parse? |
|
Tens of seconds (fork starts) |
Golden output |
Did anything at all change in what Tika extracts, for example after an upgrade? |
|
Seconds |
Server contract |
Does the tika-server we deploy still answer the endpoints, formats and status codes our client depends on? |
Testcontainers with |
About 10 to 30 seconds (container start) plus the image pull |
Corpus regression |
Did a config change or upgrade make extraction worse across thousands of real files? |
tika-app plus tika-eval (see tika-eval & Extraction Quality) |
Minutes to hours, outside the unit-test build |
and generated files] --> U[Unit tests
surefire] M[MockParser scripts] --> U M --> K[Forked failure tests
tag slow] F --> G[Golden snapshots
reviewed in PRs] F --> C[Server contract
failsafe] C --> D[(apache/tika
container)] C --> E[(Any running
tika-server)] R[Real corpus] --> V[tika-eval
regression, nightly]
The rule that keeps the suite honest: each layer asserts something specific. "Content is not empty" is not an
assertion about Tika; "the catalogue text contains Forty titles from independent publishers. and dc:title is
Spring catalogue" is.
Project setup
The examples use one Maven module. Tika goes in the compile scope because the service uses it in production; the
tika-core tests jar, which carries MockParser and TikaTest, stays strictly in test scope (with it on the main
classpath MockParser would be registered as the parser for any XML file with a <mock> root).
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<tika.version>4.1.0</tika.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- not needed under the Spring Boot parent, which manages JUnit, AssertJ and Testcontainers -->
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>6.0.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>${tika.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parsers-standard-package</artifactId>
<version>${tika.version}</version>
<type>pom</type>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-pipes-fork-parser</artifactId>
<version>${tika.version}</version>
</dependency>
<dependency>
<!-- JsonMetadataList, for golden files and for reading /rmeta responses -->
<groupId>org.apache.tika</groupId>
<artifactId>tika-serialization</artifactId>
<version>${tika.version}</version>
</dependency>
<!-- test only -->
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>${tika.version}</version>
<classifier>tests</classifier>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<version>2.0.5</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<!-- *Test classes: unit, malformed, MockParser, golden -->
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
</plugin>
<plugin>
<!-- *IT classes: the server contract, run by mvn verify -->
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<version>3.5.4</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
The Gradle equivalent (Kotlin DSL); the classifier is the third coordinate after the version:
dependencies {
implementation("org.apache.tika:tika-core:4.1.0")
implementation(platform("org.apache.tika:tika-parsers-standard-package:4.1.0"))
implementation("org.apache.tika:tika-pipes-fork-parser:4.1.0")
implementation("org.apache.tika:tika-serialization:4.1.0")
testImplementation("org.apache.tika:tika-core:4.1.0:tests")
testImplementation(platform("org.junit:junit-bom:6.0.3"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation("org.assertj:assertj-core:3.27.7")
testImplementation("org.testcontainers:testcontainers-junit-jupiter:2.0.5")
}
tasks.test {
useJUnitPlatform()
}
The Gradle build was not run for this page. With Gradle, tika-parsers-standard-package is a pom-packaged
aggregator; if platform(...) does not pull its parser modules into your configuration, depend on it without
platform and check gradle dependencies for the parser modules.
The test tree:
src/test/
├── java/bookshelf/
│ ├── Fixtures.java shared parse helpers
│ ├── FixtureExtractionTest.java text, metadata, detection, embedded documents
│ ├── ManuscriptTikaTest.java the same idea with Tika's own TikaTest base class
│ ├── MalformedFixturesTest.java empty, encrypted, unknown, truncated, explosive
│ ├── MockParserInProcessTest.java parser bugs, Errors and huge output, in-process
│ ├── ForkedFailureModesTest.java hang, OOM and crash through PipesForkParser
│ ├── GoldenOutputTest.java snapshot comparison of the recursive JSON
│ ├── TikaServerContract.java the HTTP behaviour the service relies on
│ ├── TikaServerContainerIT.java ...against apache/tika in Testcontainers
│ └── ExternalTikaServerIT.java ...against any server given by -Dtika.url
└── resources/
├── fixtures/ the Bookshelf sample files
├── golden/ reviewed JSON snapshots, one per fixture
├── mock/ MockParser scripts
└── test-documents/ files for TikaTest subclasses
Source: Apache Tika 4.1.x, The Robustness of Apache Tika
(the tests jar and MockParser),
JUnit User Guide and
Testcontainers for Java, JUnit 5.
Fixture files
Fixtures are the input half of every assertion, so they deserve the same care as code. The Bookshelf suite commits small, original files that each prove one thing:
| Fixture | Size | What it proves |
|---|---|---|
|
1.4 KB |
OOXML text, |
|
785 B |
PDF text and the document information dictionary. |
|
785 B |
A PDF with a |
|
675 B |
An image with no text: success with empty content is correct here. |
|
1 KB |
EPUB package metadata ( |
|
4.7 KB |
A message with a zip attachment holding a DOCX (with an embedded figure), a PDF and a text note: six documents in the recursive list. |
|
547 B |
HTML with a declared UTF-8 charset and Spanish text. |
|
369 B |
UTF-8 plain text with a non-ASCII file name. |
|
151 B |
windows-1252 text that must come out as |
|
16 KB |
An image-only PDF. Text appears only with OCR, so assert it only in an OCR-enabled environment (see OCR with Tesseract). |
|
1.5 KB, 6.5 KB |
Password-protected files. |
|
0 B |
A zero-byte upload. |
|
3 KB |
Random bytes with no known signature. |
Rules that keep fixtures useful:
-
Small and original. A one-page file you made yourself has no licence or privacy problem and parses in milliseconds. Never commit customer documents; if a production file exposes a bug, reproduce the structure in a new file (or with
MockParser) instead. -
One reason per file.
catalogue.pdfis not also the encryption test. When a test fails, the file name already says which behaviour broke. -
Generate what is large or dangerous. A highly compressed archive, a truncated copy or a 10,000-entry zip is built by the test in a JUnit
@TempDir(see Malformed and malicious fixtures). The repository stays small and nobody downloads a "zip bomb" by cloning it. -
Mark them binary. Add
*.pdf binary,*.docx binary(orsrc/test/resources/fixtures/** binary) to.gitattributes, so line-ending conversion never edits a fixture.legacy-catalogue.txtin particular must keep its windows-1252 bytes. -
Keep the reason next to the file. A short
READMEinfixtures/with the table above is enough.
The Tika project’s own test files live under test-documents/ directories in the parser modules of the source tree.
Those files are fine for learning what a parser does; your own fixtures should reflect the documents your users send.
Unit tests on fixture files
A shared helper
Every test class goes through one small helper, so the way the suite parses matches the way the service parses
(same parser, same handler type, same limits). AutoDetectParser is thread-safe and expensive to build (it loads the
whole parser registry), so the helper creates one per test JVM.
package bookshelf;
import java.nio.file.Path;
import java.util.List;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.parser.AutoDetectParser;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.Parser;
import org.apache.tika.parser.RecursiveParserWrapper;
import org.apache.tika.sax.BasicContentHandlerFactory;
import org.apache.tika.sax.BodyContentHandler;
import org.apache.tika.sax.RecursiveParserWrapperHandler;
/** Small helpers shared by the Bookshelf intake tests. */
final class Fixtures {
static final Path DIR = Path.of("src/test/resources/fixtures");
// AutoDetectParser is thread-safe and expensive to build: one per test JVM
static final Parser PARSER = new AutoDetectParser();
private Fixtures() {
}
static Path fixture(String name) {
return DIR.resolve(name);
}
/** Container text and metadata, in-process, no write limit. */
static Parsed parse(Path file) throws Exception {
Metadata metadata = new Metadata();
BodyContentHandler handler = new BodyContentHandler(-1);
try (TikaInputStream tis = TikaInputStream.get(file, metadata)) {
PARSER.parse(tis, handler, metadata, new ParseContext());
}
return new Parsed(handler.toString(), metadata);
}
/** One Metadata per document (container first), each with its text in tk:content. No write limit. */
static List<Metadata> parseRecursive(Path file) throws Exception {
return parseRecursive(file, -1);
}
/**
* Same, with a total write limit that truncates and flags instead of throwing. The wrapper takes its limit from
* the handler factory, not from OutputLimits in the ParseContext.
*/
static List<Metadata> parseRecursive(Path file, int writeLimit) throws Exception {
ParseContext context = new ParseContext();
RecursiveParserWrapper wrapper = new RecursiveParserWrapper(PARSER);
RecursiveParserWrapperHandler handler = new RecursiveParserWrapperHandler(
new BasicContentHandlerFactory(BasicContentHandlerFactory.HANDLER_TYPE.TEXT, writeLimit, false, context));
Metadata metadata = new Metadata();
try (TikaInputStream tis = TikaInputStream.get(file, metadata)) {
wrapper.parse(tis, handler, metadata, context);
}
return handler.getMetadataList();
}
record Parsed(String text, Metadata metadata) {
}
}
TikaInputStream.get(Path, Metadata) also records tk:resource-name and Content-Length, which is what the service
does with an upload; test with the same call. If your production code goes through PipesForkParser or tika-server
instead, the in-process helper is still the right tool for the fast tests (the parsers are the same), and the
contract tests cover the transport.
Text, metadata, type and embedded documents
package bookshelf;
import static bookshelf.Fixtures.fixture;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.apache.tika.Tika;
import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class FixtureExtractionTest {
private static final Tika TIKA = new Tika();
@Test
void manuscriptTextAndMetadata() throws Exception {
Fixtures.Parsed parsed = Fixtures.parse(fixture("manuscript.docx"));
assertThat(parsed.text())
.contains("Chapter one begins at dusk.")
.doesNotContain("�"); // no replacement characters
SoftAssertions softly = new SoftAssertions(); // report every mismatch, not just the first
softly.assertThat(parsed.metadata().get(TikaCoreProperties.TITLE)).isEqualTo("The Lighthouse Keeper");
softly.assertThat(parsed.metadata().get(TikaCoreProperties.CREATOR)).isEqualTo("Ana Torres");
softly.assertThat(parsed.metadata().get(HttpHeaders.CONTENT_TYPE))
.isEqualTo("application/vnd.openxmlformats-officedocument.wordprocessingml.document");
softly.assertAll();
}
@ParameterizedTest(name = "{0} is {1}")
@CsvSource({
"manuscript.docx, application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"catalogue.pdf, application/pdf",
"renamed-invoice.png, application/pdf", // a PDF with the wrong extension
"book-cover.jpg, image/jpeg",
"novel.epub, application/epub+zip",
"publisher-email.eml, message/rfc822",
"reviews-es.html, text/html",
"blob.bin, application/octet-stream"
})
void detectsByContentNotByName(String file, String expectedType) throws Exception {
assertThat(TIKA.detect(fixture(file))).isEqualTo(expectedType);
}
@Test
void legacyCatalogueIsDecodedAsWindows1252() throws Exception {
Fixtures.Parsed parsed = Fixtures.parse(fixture("legacy-catalogue.txt"));
assertThat(parsed.metadata().get(HttpHeaders.CONTENT_TYPE)).isEqualTo("text/plain; charset=windows-1252");
assertThat(parsed.text()).contains("Catálogo de primavera", "12€", "librería");
}
@Test
void emailAttachmentsAreParsedRecursively() throws Exception {
List<Metadata> docs = Fixtures.parseRecursive(fixture("publisher-email.eml"));
assertThat(docs).hasSize(6); // the message, the zip, three zip entries and the figure inside the docx
assertThat(docs.get(0).get(TikaCoreProperties.TITLE)).isEqualTo("Spring submissions: samples attached");
assertThat(docs)
.extracting(m -> m.get(TikaCoreProperties.EMBEDDED_RESOURCE_PATH))
.contains("/samples.zip", "/samples.zip/catalogue.pdf", "/samples.zip/chapter-one.docx",
"/samples.zip/chapter-one.docx/figure1.png");
assertThat(docs)
.allSatisfy(m -> assertThat(m.get(TikaCoreProperties.CONTAINER_EXCEPTION)).isNull())
.allSatisfy(m -> assertThat(m.getValues(TikaCoreProperties.EMBEDDED_EXCEPTION)).isEmpty());
}
}
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 18.91 s -- in bookshelf.FixtureExtractionTest
Most of the 18.91 seconds is the first AutoDetectParser and Tika construction and the first PDF and OOXML parse
(class loading); the individual tests take milliseconds afterwards. That is the argument for one shared parser per
JVM and for not forking a JVM per test class.
What the test does, and why:
-
Typed keys.
TikaCoreProperties.TITLE,CREATOR,CONTAINER_EXCEPTION,EMBEDDED_RESOURCE_PATHandHttpHeaders.CONTENT_TYPEarePropertyconstants; a typo in a string key would make an assertion onnullpass silently withisNull(). Assert the 4.x names (tk:resource-name,tk:content,tk:exception:*), never the 3.xX-TIKA:*names (see Metadata). -
contains, notisEqualTo, for text. Extracted text carries layout whitespace that changes between parser versions (the DOCX above starts with a dozen newlines). Assert sentences that must be present. Pin the whole text only in the golden files, where a change is reviewed rather than failed. -
doesNotContain("�"). The Unicode replacement character is the fingerprint of a wrong charset. Forlegacy-catalogue.txtthe positive check (€,á) is stronger still. -
Soft assertions for metadata.
SoftAssertionscollects every mismatch, so a parser upgrade that changes three keys reports all three in one run. -
Detection with the facade.
Tika.detect(Path)uses the bytes and the name. The parsedContent-Typecan be more specific (text/plain; charset=windows-1252versustext/plain), so assert the one your code actually reads. -
Embedded paths. The recursive list for the email has six entries in a fixed order: the message, the figure inside the DOCX, the DOCX, the PDF, the text note and finally the zip itself (a container’s own entry is completed after its children). Assert membership with
contains, and the size, rather than positions other than index 0, which is always the container.
Tika’s own TikaTest base class
The tika-core tests jar also ships org.apache.tika.TikaTest, the abstract base class Tika’s own parser tests
extend. It loads files from /test-documents/ on the test classpath and offers getXML, getText,
getRecursiveMetadata, assertContains, assertNotContained, assertContainsCount and
assertMetadataListEquals(listA, listB, fieldsToIgnore). In 4.1.0 its recursive helpers also switch on the strict
XHTML validator of BasicContentHandlerFactory (setValidateXHTML(true)), so a parser that emits malformed XHTML
fails the test at the offending SAX event.
package bookshelf;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import java.util.Set;
import org.apache.tika.TikaTest;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.junit.jupiter.api.Test;
/** Uses Tika's own test base class from the tika-core tests jar; files come from src/test/resources/test-documents. */
class ManuscriptTikaTest extends TikaTest {
@Test
void xhtmlOutputIsWellFormedAndHasTheTitle() throws Exception {
XMLResult result = getXML("manuscript.docx");
assertContains("<title>The Lighthouse Keeper</title>", result.xml);
assertThat(result.metadata.get(TikaCoreProperties.CREATOR)).isEqualTo("Ana Torres");
}
@Test
void recursiveMetadataIsStableAcrossRuns() throws Exception {
List<Metadata> first = getRecursiveMetadata("manuscript.docx");
List<Metadata> second = getRecursiveMetadata("manuscript.docx");
assertMetadataListEquals(first, second, Set.of("tk:parse-time-millis"));
}
}
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 9.171 s -- in bookshelf.ManuscriptTikaTest
TikaTest is convenient for a custom parser (see Extending Tika), where
you want exactly the checks Tika’s own tests make. It is test support for the Tika project, not a documented public
API: its helpers can change between releases, and it uses JUnit Jupiter assertions internally (it ran unchanged under
JUnit 6.0.3 here). For application tests the explicit helper above is just as short and keeps the parse identical to
production.
Source: TikaTest source (4.1.0).
Malformed and malicious fixtures
The happy fixtures prove that extraction works. The broken ones prove that the service fails well: with a defined exception or flag, quickly, and without taking anything else down. Each category below is a real upload the Bookshelf intake will eventually receive.
| Category | Fixture | Defined outcome in Tika 4.1.0 (observed) |
|---|---|---|
Zero-byte upload |
|
|
Password-protected |
|
|
Unknown binary |
|
|
Truncated PDF |
first half of |
No exception and empty text. The caller must notice. |
Truncated DOCX |
first half of |
|
Truncated EPUB |
first half of |
|
Highly compressed archive |
18 MB of text in a zip well under 100 KB, generated |
Text cut at the write limit and |
Parser bug, OOM, hang, crash |
|
package bookshelf;
import static bookshelf.Fixtures.fixture;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.tika.exception.EncryptedDocumentException;
import org.apache.tika.exception.TikaException;
import org.apache.tika.exception.ZeroByteFileException;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.RecursiveParserWrapper;
import org.apache.tika.sax.BasicContentHandlerFactory;
import org.apache.tika.sax.RecursiveParserWrapperHandler;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
@Timeout(30) // a malformed fixture must never hang the build
class MalformedFixturesTest {
@TempDir
Path tmp;
@Test
void emptyUploadIsRejectedWithItsOwnException() {
assertThatThrownBy(() -> Fixtures.parse(fixture("empty.bin")))
.isInstanceOf(ZeroByteFileException.class);
}
@ParameterizedTest
@ValueSource(strings = {"members-only.pdf", "members-only.docx"})
void passwordProtectedFilesAreReportedNotGuessed(String file) {
assertThatThrownBy(() -> Fixtures.parse(fixture(file)))
.isInstanceOf(EncryptedDocumentException.class);
}
@Test
void encryptedFileIsRecordedInMetadataByTheRecursiveWrapper() throws Exception {
RecursiveParserWrapper wrapper = new RecursiveParserWrapper(Fixtures.PARSER);
RecursiveParserWrapperHandler handler = new RecursiveParserWrapperHandler(
new BasicContentHandlerFactory(BasicContentHandlerFactory.HANDLER_TYPE.TEXT, -1));
Metadata metadata = new Metadata();
try (TikaInputStream tis = TikaInputStream.get(fixture("members-only.pdf"), metadata)) {
// the wrapper still throws a container exception...
assertThatThrownBy(() -> wrapper.parse(tis, handler, metadata, new ParseContext()))
.isInstanceOf(EncryptedDocumentException.class);
}
// ...after recording it in the container's metadata, which is what /rmeta and Pipes return
List<Metadata> docs = handler.getMetadataList();
assertThat(docs).hasSize(1);
assertThat(docs.get(0).get("pdf:encrypted")).isEqualTo("true");
assertThat(docs.get(0).get(TikaCoreProperties.CONTAINER_EXCEPTION))
.startsWith("org.apache.tika.exception.EncryptedDocumentException");
}
@Test
void unknownBinaryGivesNoTextAndNoException() throws Exception {
Fixtures.Parsed parsed = Fixtures.parse(fixture("blob.bin"));
assertThat(parsed.metadata().get("Content-Type")).isEqualTo("application/octet-stream");
assertThat(parsed.text()).isBlank();
}
enum Outcome { EMPTY_TEXT, TIKA_EXCEPTION }
@ParameterizedTest(name = "half of {0} gives {1}")
@CsvSource({
"catalogue.pdf, EMPTY_TEXT", // no exception at all: the caller must notice the empty text
"manuscript.docx, TIKA_EXCEPTION", // the zip directory is missing
"novel.epub, TIKA_EXCEPTION" // XML parse error in the package document
})
void truncatedUploadsFailInAKnownWay(String file, Outcome expected) throws Exception {
byte[] full = Files.readAllBytes(fixture(file));
Path truncated = tmp.resolve("truncated-" + file);
Files.write(truncated, Arrays.copyOf(full, full.length / 2));
Outcome actual;
try {
actual = Fixtures.parse(truncated).text().isBlank() ? Outcome.EMPTY_TEXT : null;
} catch (TikaException e) {
actual = Outcome.TIKA_EXCEPTION; // never an Error, a hang or an unchecked surprise
}
assertThat(actual).isEqualTo(expected);
}
@Test
void highlyCompressedArchiveIsCutAtTheWriteLimit() throws Exception {
Path bomb = tmp.resolve("samples-bomb.zip");
byte[] line = "Bookshelf catalogue line, repeated.\n".getBytes(StandardCharsets.UTF_8);
try (OutputStream out = Files.newOutputStream(bomb); ZipOutputStream zip = new ZipOutputStream(out)) {
zip.putNextEntry(new ZipEntry("catalogue-copy.txt"));
for (int i = 0; i < 500_000; i++) { // about 18 MB of text, well under 100 KB on disk
zip.write(line);
}
zip.closeEntry();
}
assertThat(Files.size(bomb)).isLessThan(100_000);
List<Metadata> docs = Fixtures.parseRecursive(bomb, 100_000); // keep what fits, flag the truncation
assertThat(docs).anySatisfy(m ->
assertThat(m.get(TikaCoreProperties.WRITE_LIMIT_REACHED)).isEqualTo("true"));
int totalChars = docs.stream()
.map(m -> m.get(TikaCoreProperties.TIKA_CONTENT))
.filter(c -> c != null)
.mapToInt(String::length)
.sum();
assertThat(totalChars).isLessThanOrEqualTo(100_000 + 1_000);
}
}
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.873 s -- in bookshelf.MalformedFixturesTest
Points worth carrying into your own suite:
-
The truncated PDF is the dangerous one. A truncated DOCX or EPUB throws; a truncated PDF parsed "successfully" to an empty string. A service that only handles exceptions would index an empty catalogue and report success. The test pins that behaviour, and the intake code must treat a PDF with no text and no
pdf:ocr-page-countas suspicious (quarantine or route to OCR). The same logic catchesscanned-loan-form.pdfwhen OCR is off. -
An outcome per category, not "does not throw". The test for truncation enumerates the outcome for each format. When a parser upgrade turns
EMPTY_TEXTintoTIKA_EXCEPTION(or the reverse), the test fails and you decide which handling is right, instead of discovering it in production. -
@Timeouton the class. A malformed file that sends a parser into a loop must fail the build, not hang it. JUnit’s@Timeoutinterrupts the test thread; a parser that ignores interrupts can still keep the JVM busy, which is one reason the failure-mode tests below go through forked JVMs. -
The recursive wrapper throws and records. For a container exception
RecursiveParserWrapperwritestk:exception:container-exceptioninto the container’s metadata and rethrows. Pipes and the/rmetaendpoint return that metadata with a success status, so assert the key when testing through them, and the exception when testing in-process. -
Generated explosive files. The compressed archive is built by the test, so the repository holds no zip bomb. The write limit comes from the handler factory: with
RecursiveParserWrapperanOutputLimitsin theParseContextalone does not cut the text (see Robustness, Limits & Timeouts for who honours which limit). Test the limit exactly where your production code sets it. -
No real malware. For security regressions (an XXE payload in a PDF form, as in CVE-2025-54988) write the smallest file that exercises the code path with a harmless entity, and run it against the hardened configuration from Security. Do not commit exploit samples collected from the wild. No such test was written for this page.
Simulating OOM, hangs and crashes with MockParser
Real files that hang a parser or exhaust its heap are rare, unpredictable and usually fixed in the next Tika release,
so they make poor fixtures. MockParser (org.apache.tika.parser.mock.MockParser, in the tika-core tests jar)
replaces them with scripts: an XML file whose root element is <mock> and whose children are actions. With the tests
jar on the classpath, AutoDetectParser and the forked JVMs route those files to MockParser by themselves (the jar
registers it through META-INF/services and declares application/mock+xml). The complete action list is in
Robustness, Limits & Timeouts;
the actions used here are:
| Action | Simulates |
|---|---|
|
Normal or huge text output. |
|
Document metadata. |
|
A parser bug. Any |
|
An infinite loop that ignores interrupts. |
|
A real |
|
|
The scripts:
<?xml version="1.0" encoding="UTF-8"?>
<!-- ok.xml: a healthy document -->
<mock>
<metadata action="add" name="dc:title">A healthy manuscript</metadata>
<write element="p">Chapter one. It was a dark and stormy night.</write>
</mock>
<?xml version="1.0" encoding="UTF-8"?>
<!-- npe.xml: a parser bug -->
<mock>
<write element="p">The parser gets this far, then hits a bug.</write>
<throw class="java.lang.NullPointerException">simulated parser bug</throw>
</mock>
<?xml version="1.0" encoding="UTF-8"?>
<!-- fake-oom.xml: reports an OutOfMemoryError without using the heap -->
<mock>
<write element="p">A parser that reports an OutOfMemoryError without allocating.</write>
<throw class="java.lang.OutOfMemoryError">simulated heap exhaustion</throw>
</mock>
hang.xml (<hang millis="60000" heavy="false" interruptible="false"/>), oom.xml (<oom/>), crash.xml
(<system_exit/>) and big.xml (<write element="p" times="2000">) follow the same pattern. Start every script
with the XML declaration: without it the file can be detected as plain text and parsed by TextAndCSVParser, and the
"hang" test passes for the wrong reason.
In-process: bugs, Errors and huge output
package bookshelf;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.nio.file.Path;
import java.util.List;
import org.apache.tika.exception.TikaException;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.junit.jupiter.api.Test;
/** MockParser comes from the tika-core tests jar (test scope) and handles any XML file with a mock root. */
class MockParserInProcessTest {
private static final Path MOCK = Path.of("src/test/resources/mock");
@Test
void mockFileIsRoutedToMockParser() throws Exception {
Fixtures.Parsed parsed = Fixtures.parse(MOCK.resolve("ok.xml"));
assertThat(parsed.text()).contains("It was a dark and stormy night.");
assertThat(parsed.metadata().getValues(TikaCoreProperties.TIKA_PARSED_BY))
.contains("org.apache.tika.parser.mock.MockParser");
}
@Test
void parserBugSurfacesAsTikaExceptionWithTheCause() {
assertThatThrownBy(() -> Fixtures.parse(MOCK.resolve("npe.xml")))
.isInstanceOf(TikaException.class)
.hasRootCauseInstanceOf(NullPointerException.class)
.hasRootCauseMessage("simulated parser bug");
}
@Test
void outOfMemoryErrorIsNotWrapped() {
// An Error escapes the parse: in-process code cannot "handle" it, which is why production forks
assertThatThrownBy(() -> Fixtures.parse(MOCK.resolve("fake-oom.xml")))
.isInstanceOf(OutOfMemoryError.class)
.hasMessage("simulated heap exhaustion");
}
@Test
void hugeOutputIsTruncatedAndFlagged() throws Exception {
List<Metadata> docs = Fixtures.parseRecursive(MOCK.resolve("big.xml"), 1_000);
assertThat(docs.get(0).get(TikaCoreProperties.WRITE_LIMIT_REACHED)).isEqualTo("true");
assertThat(docs.get(0).get(TikaCoreProperties.TIKA_CONTENT)).hasSizeLessThanOrEqualTo(1_100);
}
}
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.029 s -- in bookshelf.MockParserInProcessTest
The first test is the canary: if tk:parsed-by does not list MockParser, every other mock test is meaningless.
The second pins what your error handling receives from a buggy parser: AutoDetectParser wraps an unexpected
RuntimeException in a TikaException (message "Unexpected RuntimeException from …MockParser@…"), with the
original as the cause. The third is the reason in-process parsing of untrusted files is not enough: an
OutOfMemoryError is an Error, not wrapped, and a real one leaves the JVM in an unknown state. Use the thrown
OutOfMemoryError only in-process; <oom/> and <system_exit/> belong in a forked JVM, never in the test JVM, which
they would take down with the build.
Source: MockParser source (4.1.0).
Forked: hang, OOM and crash, then recovery
The production Bookshelf worker parses through PipesForkParser, which runs each parse in a forked JVM and restarts
it after a crash (see Tika Pipes). What matters is the contract: a hang ends
as TIMEOUT, an exhausted heap as OOM, a dead JVM as UNSPECIFIED_CRASH, and in every case the next document
parses normally. The forks inherit the test classpath, so they see the tests jar and MockParser too.
package bookshelf;
import static org.assertj.core.api.Assertions.assertThat;
import java.nio.file.Path;
import org.apache.tika.config.TimeoutLimits;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.pipes.api.PipesResult;
import org.apache.tika.pipes.fork.PipesForkParser;
import org.apache.tika.pipes.fork.PipesForkParserConfig;
import org.apache.tika.pipes.fork.PipesForkResult;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
@Tag("slow") // starts forked JVMs: about 5 s per fork start
@Timeout(120)
class ForkedFailureModesTest {
private static final Path MOCK = Path.of("src/test/resources/mock");
private static PipesForkParser parser;
@BeforeAll
static void startParser() throws Exception {
parser = new PipesForkParser(new PipesForkParserConfig()
.setTimeoutLimits(new TimeoutLimits(30_000, 3_000)) // 30 s per document, 3 s without progress
.addJvmArg("-Xmx256m")); // small heap: oom.xml fails fast
}
@AfterAll
static void stopParser() throws Exception {
parser.close(); // kills the forks
}
@ParameterizedTest(name = "{0} -> {1}, then the next file still parses")
@CsvSource({
"hang.xml, TIMEOUT",
"oom.xml, OOM",
"crash.xml, UNSPECIFIED_CRASH"
})
void survivesTheFailureAndRecovers(String file, PipesResult.RESULT_STATUS expected) throws Exception {
try (TikaInputStream bad = TikaInputStream.get(MOCK.resolve(file))) {
PipesForkResult result = parser.parse(bad);
assertThat(result.getStatus()).isEqualTo(expected);
assertThat(result.isProcessCrash()).isTrue();
assertThat(result.isSuccess()).isFalse();
}
try (TikaInputStream good = TikaInputStream.get(MOCK.resolve("ok.xml"))) {
PipesForkResult result = parser.parse(good); // served by a fresh fork
assertThat(result.getStatus()).isEqualTo(PipesResult.RESULT_STATUS.PARSE_SUCCESS);
assertThat(result.getContent()).contains("It was a dark and stormy night.");
}
}
}
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 42.38 s -- in bookshelf.ForkedFailureModesTest
A later full run of the same class took 101.6 s on the same machine, with the rest of the build competing for the
CPU: most of the time is fork starts (one at the beginning and one after each failure) and the 3-second stall
detector on hang.xml. Hence @Tag("slow"), so the class can run in a separate CI step
(mvn test -Dgroups=slow, mvn test -DexcludedGroups=slow), and the short progress timeout. Keep the timeouts short
in tests but in the same shape as production (a total budget plus a progress stall), so the test exercises the
same timeout path. PipesForkParser is AutoCloseable; a @BeforeAll / @AfterAll pair (or try-with-resources in a
single test) makes sure no fork outlives the test JVM.
The same approach works one level up: feed the scripts to tika-server (they need the tests jar on the server’s
classpath, for example in the image’s /tika-extras directory) and assert 503 with a JSON status of TIMEOUT, OOM
or UNSPECIFIED_CRASH, then 200 for the next request. That variant was not run for this page.
Source: Apache Tika 4.1.x, The Robustness of Apache Tika
("Testing your integration with MockParser"),
Apache Tika 4.1.0 Javadoc, PipesForkParser
and PipesForkParserExample in tika-example (4.1.0).
Golden-output comparison
Assertions catch the changes you thought of. A golden (snapshot) test catches the rest: it stores the complete extraction of each fixture, reviewed once, and fails on any difference. That is what turns "upgrade Tika from 4.1.0 to 4.1.1" from a leap of faith into a pull request with a readable diff of every key and every line of text that changed.
The stored form is the recursive JSON list, the same shape /rmeta and Tika Pipes return, written with
JsonMetadataList from tika-serialization:
package bookshelf;
import static bookshelf.Fixtures.fixture;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.StringReader;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.serialization.JsonMetadataList;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
/**
* Compares the full /rmeta-style output of each fixture with a reviewed snapshot in src/test/resources/golden.
* Regenerate after an intended change with: mvn test -Dtest=GoldenOutputTest -Dgolden.update=true
*/
class GoldenOutputTest {
private static final Path GOLDEN = Path.of("src/test/resources/golden");
private static final boolean UPDATE = Boolean.getBoolean("golden.update");
// values that change from run to run or machine to machine
private static final Set<String> VOLATILE = Set.of("tk:parse-time-millis", "tk:embedded-id");
@ParameterizedTest
@ValueSource(strings = {"manuscript.docx", "catalogue.pdf", "legacy-catalogue.txt", "publisher-email.eml"})
void matchesTheReviewedSnapshot(String file) throws Exception {
String actual = toStableJson(Fixtures.parseRecursive(fixture(file)));
Path golden = GOLDEN.resolve(file + ".json");
if (UPDATE || Files.notExists(golden)) {
Files.createDirectories(GOLDEN);
Files.writeString(golden, actual, StandardCharsets.UTF_8);
}
String expected = Files.readString(golden, StandardCharsets.UTF_8);
assertThat(actual)
.as("extraction of %s changed; review the diff, then regenerate with -Dgolden.update=true", file)
.isEqualTo(expected);
}
/** Drops volatile keys and pretty-prints, so the snapshot diffs line by line. */
static String toStableJson(List<Metadata> docs) throws Exception {
List<Metadata> stable = new ArrayList<>();
for (Metadata doc : docs) {
Metadata copy = new Metadata();
copy.putAll(doc); // 4.x refuses copy.add("tk:...", value) for reserved keys
VOLATILE.forEach(copy::remove);
stable.add(copy);
}
StringWriter json = new StringWriter();
JsonMetadataList.toJson(stable, json, true); // true = pretty print
// round trip once, so the snapshot is exactly what Tika reads back
List<Metadata> reread = JsonMetadataList.fromJson(new StringReader(json.toString()));
assertThat(reread).hasSameSizeAs(stable);
return json.toString().replace("\r\n", "\n") + "\n";
}
}
The snapshot written for manuscript.docx:
[ {
"Content-Length" : "1418",
"Content-Type" : "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"dc:creator" : "Ana Torres",
"dc:title" : "The Lighthouse Keeper",
"tk:content-handler-type" : "TEXT",
"tk:content-type-magic-detected" : "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"tk:embedded-depth" : "0",
"tk:parsed-by" : [ "org.apache.tika.parser.DefaultParser", "org.apache.tika.parser.microsoft.ooxml.OOXMLParser", "org.apache.tika.parser.microsoft.ooxml.xwpf.XWPFEventBasedWordExtractor" ],
"tk:parsed-by-full-set" : [ "org.apache.tika.parser.DefaultParser", "org.apache.tika.parser.microsoft.ooxml.OOXMLParser" ],
"tk:resource-name" : "manuscript.docx",
"zip:detector-zip-file-opened" : "true",
"tk:content" : "\n\n\n\n\n\n\n\n\n\n\n\nThe Lighthouse Keeper\n\nThe Lighthouse Keeper\nChapter one begins at dusk.\n"
} ]
The first run writes the four snapshots (and passes); a second run compared them and passed, so the output of these
fixtures is deterministic once tk:parse-time-millis is removed. To see a failure, the stored dc:creator was edited
to A. Torres:
[ERROR] bookshelf.GoldenOutputTest.matchesTheReviewedSnapshot(String)[1] -- Time elapsed: 11.41 s <<< FAILURE!
org.opentest4j.AssertionFailedError:
[extraction of manuscript.docx changed; review the diff, then regenerate with -Dgolden.update=true]
expected:
"[ {
"Content-Length" : "1418",
"Content-Type" : "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"dc:creator" : "A. Torres",
...
Because the failure is an AssertionFailedError with expected and actual strings, IDEs show it as a side-by-side
diff.
How to run golden tests well:
-
Review, then regenerate.
-Dgolden.update=truerewrites the files; the diff ofsrc/test/resources/golden/in the pull request is the review. Never regenerate to make a red build green without reading it. -
Drop only what is truly volatile.
tk:parse-time-millisalways changes. Keeptk:parsed-by: when an upgrade routes a format to a different parser, that line is the most important one in the diff. If you add a digester, keep the digest (it is stable for the same bytes); drop dates only if your code stamps them. -
One file per fixture, pretty-printed. A diff that says "line 14 of
publisher-email.eml.jsonchanged" is reviewable; one JSON line for the whole corpus is not. -
Write with
putAll, not with string keys. In 4.xMetadata.add(String, String)andset(String, String)refuse reservedtk:names (IllegalArgumentException: "Writing reserved key 'tk:resource-name' via the String API is not allowed…"). The first version of this test copied keys that way and failed on every fixture. See the reserved-key write guard. -
Expect a large diff on every minor upgrade. New metadata keys appear (the PDF snapshot carries keys such as
pdf:has-xfaandpdf:incremental-update-count); review them like any other dependency change. -
Golden files are not quality metrics. They say that extraction changed, not whether it got better. For "better or worse across ten thousand files", use the tika-eval comparison (below and on tika-eval & Extraction Quality).
TikaTest.assertMetadataListEquals(listA, listB, fieldsToIgnore) (shown above) is the in-memory alternative: compare
two parses (for example with two configurations) without writing files.
Testing against tika-server with Testcontainers
When the service calls tika-server over HTTP (the first architecture on
Spring Boot Integration), the risky part is not the parsing but
the contract: endpoint paths, Accept negotiation, the JSON shape of /rmeta, and the status codes 422, 429 and
503 that drive retries. Those changed substantially between 3.x and 4.x (see
Tika Server (REST API)), and they are exactly what a mocked HTTP
client cannot test. Testcontainers starts the real apache/tika image for the test and removes it afterwards.
The contract as an abstract test class
The assertions live in one abstract class; subclasses only say where the server is. The same contract then runs against a container in CI, against a locally started server, or against a staging deployment.
package bookshelf;
import static bookshelf.Fixtures.fixture;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.StringReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublishers;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.time.Duration;
import java.util.List;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.serialization.JsonMetadataList;
import org.junit.jupiter.api.Test;
/** The HTTP behaviour the Bookshelf intake service relies on. Subclasses say where the server is. */
abstract class TikaServerContract {
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
/** For example http://localhost:32791 */
abstract URI baseUri();
@Test
void reportsItsVersion() throws Exception {
HttpResponse<String> response = send(HttpRequest.newBuilder(uri("/version")).GET());
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).startsWith("Apache Tika 4.1");
}
@Test
void detectsTheRenamedInvoiceAsPdf() throws Exception {
HttpResponse<String> response = put("/detect", "renamed-invoice.png", "text/plain");
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).isEqualTo("application/pdf");
}
@Test
void extractsCatalogueText() throws Exception {
HttpResponse<String> response = put("/tika/text", "catalogue.pdf", "text/plain");
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).contains("Forty titles from independent publishers.");
}
@Test
void returnsEveryEmbeddedDocumentFromRmeta() throws Exception {
HttpResponse<String> response = put("/rmeta/text", "publisher-email.eml", "application/json");
assertThat(response.statusCode()).isEqualTo(200);
List<Metadata> docs = JsonMetadataList.fromJson(new StringReader(response.body()));
assertThat(docs).hasSize(6);
assertThat(docs).extracting(m -> m.get(TikaCoreProperties.EMBEDDED_RESOURCE_PATH))
.contains("/samples.zip/catalogue.pdf");
}
@Test
void encryptedPdfIsUnprocessableOnTheRawEndpoint() throws Exception {
HttpResponse<String> response = put("/tika/text", "members-only.pdf", "text/plain");
assertThat(response.statusCode()).isEqualTo(422);
}
@Test
void encryptedPdfIsReportedInsideRmeta() throws Exception {
HttpResponse<String> response = put("/rmeta", "members-only.pdf", "application/json");
assertThat(response.statusCode()).isEqualTo(200);
List<Metadata> docs = JsonMetadataList.fromJson(new StringReader(response.body()));
assertThat(docs.get(0).get(TikaCoreProperties.CONTAINER_EXCEPTION))
.contains("EncryptedDocumentException");
}
@Test
void perRequestConfigIsDisabledByDefault() throws Exception {
HttpResponse<String> response = send(HttpRequest.newBuilder(uri("/tika/config"))
.header("Content-Type", "multipart/form-data; boundary=x")
.POST(BodyPublishers.ofString("--x--\r\n")));
assertThat(response.statusCode()).isEqualTo(403);
}
@Test
void unknownPathsAndMethodsAreRejected() throws Exception {
assertThat(send(HttpRequest.newBuilder(uri("/nonexistent")).GET()).statusCode()).isEqualTo(404);
assertThat(send(HttpRequest.newBuilder(uri("/tika/text")).DELETE()).statusCode()).isEqualTo(405);
}
private HttpResponse<String> put(String path, String fixtureName, String accept) throws Exception {
return send(HttpRequest.newBuilder(uri(path))
.header("Accept", accept)
.PUT(BodyPublishers.ofFile(fixture(fixtureName))));
}
private HttpResponse<String> send(HttpRequest.Builder request) throws Exception {
return HTTP.send(request.timeout(Duration.ofSeconds(60)).build(), BodyHandlers.ofString());
}
private URI uri(String path) {
return baseUri().resolve(path);
}
}
Two details matter. JsonMetadataList.fromJson reads the /rmeta body into the same Metadata objects the in-process
tests use, so the assertions (and TikaCoreProperties constants) are shared. And perRequestConfigIsDisabledByDefault
is a security assertion: if someone ships a configuration with allowPerRequestConfig=true, the contract fails.
The container subclass
package bookshelf;
import java.net.URI;
import java.time.Duration;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
@Testcontainers(disabledWithoutDocker = true) // skipped, not failed, on a machine without Docker
class TikaServerContainerIT extends TikaServerContract {
@Container
static final GenericContainer<?> TIKA = new GenericContainer<>(DockerImageName.parse("apache/tika:4.1.0-1"))
.withExposedPorts(9998)
.waitingFor(Wait.forHttp("/version").forStatusCode(200))
.withStartupTimeout(Duration.ofMinutes(2));
@Override
URI baseUri() {
return URI.create("http://" + TIKA.getHost() + ":" + TIKA.getMappedPort(9998));
}
}
-
Pin the image tag.
apache/tika:4.1.0-1is the same image the service deploys (see Docker & Kubernetes Deployment);latestwould let the test pass against a server you do not run. Use the-fulltag if production relies on Tesseract, and only then assert OCR text forscanned-loan-form.pdf. -
Wait for
/version. It answers as soon as the HTTP layer is up, before any fork has started; the first parse request then pays the fork start. Give the HTTP client a generous timeout (60 s above) for the first request, or warm the server up in a@BeforeAllwith one small parse. -
A static
@Containerstarts one container for the class. To share one across classes, use the singleton pattern from Testcontainers Fundamentals. -
Custom configuration. The image’s entrypoint passes its arguments to
TikaServerCli, so a test configuration can be copied in withwithCopyFileToContainer(MountableFile.forClasspathResource("tika-config-test.json"), "/tika-config.json")and selected withwithCommand("-c", "/tika-config.json"). The file needs aserversection (an empty"server": {}is enough), or the 4.1.0 server refuses to start; the entry point keeps its own-h 0.0.0.0, so the host in that section does not matter inside the container. This variant was not run for this page. -
disabledWithoutDocker = true. On a developer machine without Docker the class is reported as skipped. In CI, where Docker is required, prefer to fail: drop the flag in a CI profile, or check the skip count. -
Spring Boot. In a Spring Boot 4 test, the same container can be declared as a bean and its URL published with
@DynamicPropertySource(see Spring Boot Integration Tests with Testcontainers). There is no Tika service connection in Spring Boot, so the property is set by hand.
The same contract against any running server
package bookshelf;
import java.net.URI;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
/** The same contract against a server you started yourself: mvn verify -Dtika.url=http://localhost:9998 */
@EnabledIfSystemProperty(named = "tika.url", matches = "https?://.+")
class ExternalTikaServerIT extends TikaServerContract {
@Override
URI baseUri() {
return URI.create(System.getProperty("tika.url"));
}
}
Run on the test machine (no Docker daemon) with the 4.1.0 zip distribution started on a spare port:
unzip -q tika-server-standard-4.1.0.zip -d tika-server && cd tika-server
java -jar tika-server-standard-4.1.0.jar -p 19998 -h 127.0.0.1 &
cd ../bookshelf-intake-tests
mvn verify -Dtika.url=http://127.0.0.1:19998
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 24.59 s -- in bookshelf.ExternalTikaServerIT
ERROR [main] org.testcontainers.dockerclient.DockerClientProviderStrategy Could not find a valid Docker environment. ...
[WARNING] Tests run: 8, Failures: 0, Errors: 0, Skipped: 8, Time elapsed: 0.007 s -- in bookshelf.TikaServerContainerIT
[WARNING] Tests run: 16, Failures: 0, Errors: 0, Skipped: 8
So all eight contract checks pass against a real tika-server 4.1.0 (the jar the apache/tika:4.1.0-1 image runs), and
the container class skips cleanly when Docker is missing. The container run itself is the one thing on this page that
could not be executed. The same subclass pattern lets a staging smoke test run the contract against the deployed
service after each release.
The official integration-test checklists
The Tika project documents the checks it runs against its own distributions, under Advanced, Integration testing in the 4.1.x docs. They are written for release managers testing a build of Tika, but they are the best available list of "what a working tika-server or tika-app does", and most items translate directly into contract tests for your deployment.
tika-server
The tika-server page is a manual walkthrough of about thirty numbered tests against the server started from the zip distribution, in three groups:
| Group | What is checked | Already in the contract above? |
|---|---|---|
Default mode |
|
|
With |
The |
No (the Bookshelf server keeps both off) |
Options, headers and errors |
Custom port, host and config file; removed |
|
Two notes from that page are worth copying into your own tests: the /language check asserts only "two or three
lowercase letters", because a short test file is not enough for reliable language detection; and the per-request
configuration must use the top-level form, since the nested parsers form is rejected (with a 500, which the page
records as a status-code issue to fix).
run-uat.sh and the e2e test
The manual walkthrough is automated as release-tools/uat/run-uat.sh in the Tika source tree: a bash and curl
script that runs against an already running server (run-uat.sh [base-url], default http://localhost:9998), exits
0 when every check passes and 1 otherwise, and needs four test files next to it. It covers the default-mode
endpoints, the 403 gating, handler selection (/tika/json defaults to Markdown, an unknown handler name is a 400),
content negotiation for XMP on /meta, exception reporting on a corrupt document (422 on the raw endpoints, the
exception inside /rmeta), and OCR when the server has Tesseract (TIKA_UAT_REQUIRE_OCR=1 makes a missing OCR a
failure). An optional inference pass runs against a small mock inference server shipped next to the script.
The project uses the same script three ways: as the Docker image smoke test (docker-tool.sh test-uat starts the
minimal and the -full image and runs it against each), as the JUnit e2e test RunUatSmokeTest in
tika-e2e-tests/tika-server (which forks the server jar on a free port, waits for it, runs the script and asserts exit
code 0; skipped on Windows), and as part of release verification.
For your own deployment, the script is a ready-made smoke test of a custom image: build your image FROM
apache/tika:4.1.0-1 with your configuration, start it, and run the script from a Tika source checkout of the same
version against it before tagging. Checks for endpoints you deliberately disabled will fail and need to be removed or
expected. The script was read, not run, for this page.
tika-app
The tika-app page lists about thirty checks for the CLI distribution zip, run from the unzipped directory so the
lib/ and plugins/ siblings are found: --text, --metadata, --json --pretty-print, --detect, standard input,
--jsonRecursive, attachment extraction with -z and -Z into --extract-dir, batch mode (<inputDir> <outputDir>,
with -n workers, -T timeout and --Xmx heap), --version, --list-parsers, --language, --digest=md5, URL
input, --xmp, --text-main, --maxEmbeddedDepth, a custom tika-config.json with fetchers, emitters and a pipes
iterator, and the unpack formats. Its "known issues" are useful to anyone scripting the CLI: a missing file reports a
confusing MalformedURLException: no protocol, default-config commands print an INFO line to standard error, and the
config-dump options are not implemented in 4.x. If your pipeline shells out to tika-app, turn the commands you use
into tests that assert exit codes and output, as on
Tika App (Command Line & GUI).
tika-eval regression
The fourth page is a recipe for comparing two Tika configurations (or versions) over the same corpus: extract the
corpus twice with tika-app (-i, -o, -c, -n), with a SHA-256 digester configured so embedded documents can be
aligned across runs, then run tika-eval-app with -a and -b to load both extract trees into a database and produce
spreadsheet reports of differences in MIME types, content, exceptions, embedded documents and metadata. This is the
corpus-scale big brother of the golden tests above, and the right gate before a Tika upgrade in production: the
golden files tell you what changed for twenty fixtures, tika-eval tells you how the change is distributed over twenty
thousand real files. The details are on
tika-eval & Extraction Quality.
Running the suites
mvn test # unit, malformed, MockParser in-process and forked, golden
mvn test -DexcludedGroups=slow # the quick loop while developing
mvn test -Dtest=GoldenOutputTest -Dgolden.update=true # after an intended change, then review the diff
mvn verify # adds the *IT classes: the container contract
mvn verify -Dtika.url=http://127.0.0.1:9998 # also the contract against a running server
The full mvn verify on the test machine (no Docker, no tika.url):
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 18.91 s -- in bookshelf.FixtureExtractionTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.249 s -- in bookshelf.GoldenOutputTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 9.171 s -- in bookshelf.ManuscriptTikaTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 101.6 s -- in bookshelf.ForkedFailureModesTest
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.873 s -- in bookshelf.MalformedFixturesTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.029 s -- in bookshelf.MockParserInProcessTest
[INFO] Tests run: 33, Failures: 0, Errors: 0, Skipped: 0
[WARNING] Tests run: 8, Failures: 0, Errors: 0, Skipped: 8, Time elapsed: 0.005 s -- in bookshelf.ExternalTikaServerIT
[WARNING] Tests run: 8, Failures: 0, Errors: 0, Skipped: 8, Time elapsed: 0.003 s -- in bookshelf.TikaServerContainerIT
[WARNING] Tests run: 16, Failures: 0, Errors: 0, Skipped: 8
[INFO] BUILD SUCCESS
The Tika parsers log through SLF4J; a log4j-slf4j2-impl binding with a quiet
log4j2-test.xml keeps expected failures (the truncated EPUB logs a FATAL from POI’s XML helper) out of the build
output.
A CI pipeline that mirrors the layers: the quick loop on every push, the slow tag and the container contract on pull requests (Docker is available on hosted Linux runners), the golden diff reviewed as part of the pull request, and a nightly or pre-upgrade tika-eval job over a real corpus outside the Maven build.
Earlier versions and the book
-
Tika 1.x (Tika in Action). The book’s examples predate JUnit 5, Testcontainers and the recursive JSON; Tika’s own 1.x tests used JUnit 4 and
TikaTestwithX-TIKA:keys. The book has no chapter on testing integrations and is not cited for this page. -
Tika 2.x and 3.x.
TikaTestandMockParseralready lived in thetika-coretests jar, but the metadata keys wereX-TIKA:content,X-TIKA:EXCEPTION:container_exception,X-TIKA:EXCEPTION:write_limit_reached,X-TIKA:embedded_resource_pathandX-TIKA:Parsed-By. Golden files written with 3.x will differ on almost every key after the upgrade; regenerate them once, deliberately, in the upgrade pull request, after a tika-eval comparison. Failure-mode tests usedForkParser(removed in 4.x) or the server’s spawned child (also removed); in 4.x they targetPipesForkParser, tika-server or tika-grpc, and the result statuses arePipesResult.RESULT_STATUSvalues. -
Tika 4.x. Reserved
tk:keys cannot be written through the string API, which affects any test helper that rebuildsMetadata(useputAllor thePropertyconstants). tika-server 4.x answers/tika/textand/rmetawith the parse running in a forked JVM, so a server contract written for 3.x needs updating for the new paths, the removedX-Tika-*headers and the429/503split (see Tika Server (REST API) and What’s Changed & Migration).
Pitfalls
-
Asserting "not empty". A truncated PDF, an image-only PDF without OCR and an unknown binary all return empty text without an exception. Assert specific sentences, and test the empty cases explicitly.
-
Asserting exact whitespace. Layout newlines change between parser versions. Use
contains, or golden files where changes are reviewed. -
String metadata keys.
get("tk:contnet")returnsnull; useTikaCorePropertiesand the otherPropertyconstants. -
The tests jar in the wrong scope. In
compilescopeMockParserships to production and claims every<mock>XML file. -
Mock scripts without the XML declaration. They may be parsed as text, and the hang or crash test then passes without exercising anything. Keep the
tk:parsed-bycanary test. -
Running
<oom/>or<system_exit/>in the test JVM. They take down the build. Only through forks or a server. -
A test that hangs instead of failing. Put
@Timeouton every class that parses untrusted or malformed input. -
A new
AutoDetectParserper test. Each construction reloads the parser registry; share one per JVM. -
Committed zip bombs and customer files. Generate dangerous files in a
@TempDir; build fixtures yourself. -
latestin the container test. Pin the same tag you deploy. -
Silent skips in CI.
disabledWithoutDocker = trueturns a missing Docker into green; fail in CI instead. -
Regenerating golden files without reading the diff. It defeats the point of having them.
-
Write limits set where the code under test does not read them. Test the limit through the same API (handler factory,
ParseContext,PipesForkParserConfig.setWriteLimitor server configuration) that production uses.
What was verified and what was not
Compiled and run against Tika 4.1.0 on Java 21 (Maven 3.9, surefire and failsafe 3.5.4, JUnit Jupiter 6.0.3 and 5.13.4, AssertJ 3.27.7 and 3.27.6, Testcontainers 2.0.5), with the outputs shown:
-
FixtureExtractionTest,ManuscriptTikaTest,MalformedFixturesTest,MockParserInProcessTest,ForkedFailureModesTestandGoldenOutputTest, including the behaviour of the truncated PDF, DOCX and EPUB, the reserved-key refusal in the first version of the golden helper, the deterministic second golden run and the failure message after editing a snapshot. -
TikaServerContractthroughExternalTikaServerIT, againsttika-server-standard-4.1.0.jarfrom the 4.1.0 zip distribution started locally: all eight checks passed (/version,/detect,/tika/text,/rmeta/text,422for the encrypted PDF on/tika/text, the container exception inside/rmeta,403on/tika/config,404and405). -
TikaServerContainerITcompiled, and was skipped because the test machine had no Docker daemon.
Read from the sources and documentation but not run:
-
TikaServerContainerITagainst theapache/tika:4.1.0-1image (the image runs the same server jar, but the image itself was not exercised), and the custom-configuration variant withwithCopyFileToContainerandwithCommand. -
The Gradle build.
-
run-uat.sh,RunUatSmokeTest,docker-tool.sh test-uatand the tika-app and tika-eval checklists, which are summarised from the 4.1.x documentation and the 4.1.0 source tree. -
The
MockParsertests through tika-server (503withTIMEOUT,OOMorUNSPECIFIED_CRASH). -
Any security-regression fixture (XXE or similar).