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, AutoDetectParser or the Tika facade in-process, committed fixtures

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 @TempDir, @Timeout, write limits

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?

MockParser from the tika-core tests jar, in-process and through PipesForkParser

Tens of seconds (fork starts)

Golden output

Did anything at all change in what Tika extracts, for example after an upgrade?

RecursiveParserWrapper, JsonMetadataList, reviewed snapshot files

Seconds

Server contract

Does the tika-server we deploy still answer the endpoints, formats and status codes our client depends on?

Testcontainers with apache/tika, the JDK HttpClient, a reusable abstract test class

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

flowchart LR F[Committed fixtures
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

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

manuscript.docx

1.4 KB

OOXML text, dc:title and dc:creator from the core properties.

catalogue.pdf

785 B

PDF text and the document information dictionary.

renamed-invoice.png

785 B

A PDF with a .png name: detection must trust the bytes, not the extension.

book-cover.jpg

675 B

An image with no text: success with empty content is correct here.

novel.epub

1 KB

EPUB package metadata (dc:language) and chapter text.

publisher-email.eml

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.

reviews-es.html

547 B

HTML with a declared UTF-8 charset and Spanish text.

reseñas-fr.txt

369 B

UTF-8 plain text with a non-ASCII file name.

legacy-catalogue.txt

151 B

windows-1252 text that must come out as € and á, not mojibake. This suite’s own short variant; other pages of the section use longer ones (234 bytes on Character Encoding Detection, 431 bytes on tika-eval & Extraction Quality).

scanned-loan-form.pdf

16 KB

An image-only PDF. Text appears only with OCR, so assert it only in an OCR-enabled environment (see OCR with Tesseract).

members-only.pdf, members-only.docx

1.5 KB, 6.5 KB

Password-protected files.

empty.bin

0 B

A zero-byte upload.

blob.bin

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.pdf is 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 (or src/test/resources/fixtures/** binary) to .gitattributes, so line-ending conversion never edits a fixture. legacy-catalogue.txt in particular must keep its windows-1252 bytes.

  • Keep the reason next to the file. A short README in fixtures/ 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_PATH and HttpHeaders.CONTENT_TYPE are Property constants; a typo in a string key would make an assertion on null pass silently with isNull(). Assert the 4.x names (tk:resource-name, tk:content, tk:exception:*), never the 3.x X-TIKA:* names (see Metadata).

  • contains, not isEqualTo, 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. For legacy-catalogue.txt the positive check (€, á) is stronger still.

  • Soft assertions for metadata. SoftAssertions collects 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 parsed Content-Type can be more specific (text/plain; charset=windows-1252 versus text/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.

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

empty.bin

ZeroByteFileException from AutoDetectParser; with the recursive wrapper also tk:exception:container-exception.

Password-protected

members-only.pdf, members-only.docx

EncryptedDocumentException. The PDF also reports pdf:encrypted=true; the DOCX is detected as application/x-tika-ooxml-protected.

Unknown binary

blob.bin

application/octet-stream, no text, no exception (the EmptyParser).

Truncated PDF

first half of catalogue.pdf, generated

No exception and empty text. The caller must notice.

Truncated DOCX

first half of manuscript.docx, generated

TikaException: "No OOXML extractor found for content type: null".

Truncated EPUB

first half of novel.epub, generated

TikaException "XML parse error", caused by a SAXParseException.

Highly compressed archive

18 MB of text in a zip well under 100 KB, generated

Text cut at the write limit and tk:exception:write-limit-reached=true.

Parser bug, OOM, hang, crash

MockParser scripts

See Simulating OOM, hangs and crashes with MockParser.

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-count as suspicious (quarantine or route to OCR). The same logic catches scanned-loan-form.pdf when 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_TEXT into TIKA_EXCEPTION (or the reverse), the test fails and you decide which handling is right, instead of discovering it in production.

  • @Timeout on the class. A malformed file that sends a parser into a loop must fail the build, not hang it. JUnit’s @Timeout interrupts 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 RecursiveParserWrapper writes tk:exception:container-exception into the container’s metadata and rethrows. Pipes and the /rmeta endpoint 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 RecursiveParserWrapper an OutputLimits in the ParseContext alone 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

<write element="p" times="2000">text</write>

Normal or huge text output.

<metadata action="add" name="dc:title">value</metadata>

Document metadata.

<throw class="java.lang.NullPointerException">message</throw>

A parser bug. Any Throwable works, including an OutOfMemoryError thrown without allocating.

<hang millis="60000" heavy="false" interruptible="false"/>

An infinite loop that ignores interrupts.

<oom/>

A real OutOfMemoryError: allocates until the heap is exhausted.

<system_exit/>

System.exit(1), the stand-in for a native crash.

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.

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.

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=true rewrites the files; the diff of src/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-millis always changes. Keep tk: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.json changed" is reviewable; one JSON line for the whole corpus is not.

  • Write with putAll, not with string keys. In 4.x Metadata.add(String, String) and set(String, String) refuse reserved tk: 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-xfa and pdf: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-1 is the same image the service deploys (see Docker & Kubernetes Deployment); latest would let the test pass against a server you do not run. Use the -full tag if production relies on Tesseract, and only then assert OCR text for scanned-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 @BeforeAll with one small parse.

  • A static @Container starts 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 with withCopyFileToContainer(MountableFile.forClasspathResource("tika-config-test.json"), "/tika-config.json") and selected with withCommand("-c", "/tika-config.json"). The file needs a server section (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

/version; /detect; /tika in text, HTML, XML and JSON; /meta and /meta/{field}; /rmeta and /rmeta/text; /language; /unpack/all (a zip with the embedded files and their metadata); /parsers, /detectors, /mime-types; the multipart /meta/form and /rmeta/form; the /config variants blocked with 403; a server with pipes or async endpoints but without allowPipes refusing to start

/version, /detect, /tika/text, /rmeta, one /config variant

With allowPipes and allowPerRequestConfig

The /config endpoints accepting a per-request configuration in the top-level self-configuring form ({"pdf-parser":{...}}), and /unpack/all/config

No (the Bookshelf server keeps both off)

Options, headers and errors

Custom port, host and config file; removed X-Tika-* headers ignored rather than rejected; the file name taken from Content-Disposition; 404 for an unknown path and 405 for a wrong method

404, 405

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 TikaTest with X-TIKA: keys. The book has no chapter on testing integrations and is not cited for this page.

  • Tika 2.x and 3.x. TikaTest and MockParser already lived in the tika-core tests jar, but the metadata keys were X-TIKA:content, X-TIKA:EXCEPTION:container_exception, X-TIKA:EXCEPTION:write_limit_reached, X-TIKA:embedded_resource_path and X-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 used ForkParser (removed in 4.x) or the server’s spawned child (also removed); in 4.x they target PipesForkParser, tika-server or tika-grpc, and the result statuses are PipesResult.RESULT_STATUS values.

  • Tika 4.x. Reserved tk: keys cannot be written through the string API, which affects any test helper that rebuilds Metadata (use putAll or the Property constants). tika-server 4.x answers /tika/text and /rmeta with the parse running in a forked JVM, so a server contract written for 3.x needs updating for the new paths, the removed X-Tika-* headers and the 429/503 split (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") returns null; use TikaCoreProperties and the other Property constants.

  • The tests jar in the wrong scope. In compile scope MockParser ships 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-by canary 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 @Timeout on every class that parses untrusted or malformed input.

  • A new AutoDetectParser per 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.

  • latest in the container test. Pin the same tag you deploy.

  • Silent skips in CI. disabledWithoutDocker = true turns 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.setWriteLimit or 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, ForkedFailureModesTest and GoldenOutputTest, 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.

  • TikaServerContract through ExternalTikaServerIT, against tika-server-standard-4.1.0.jar from the 4.1.0 zip distribution started locally: all eight checks passed (/version, /detect, /tika/text, /rmeta/text, 422 for the encrypted PDF on /tika/text, the container exception inside /rmeta, 403 on /tika/config, 404 and 405).

  • TikaServerContainerIT compiled, and was skipped because the test machine had no Docker daemon.

Read from the sources and documentation but not run:

  • TikaServerContainerIT against the apache/tika:4.1.0-1 image (the image runs the same server jar, but the image itself was not exercised), and the custom-configuration variant with withCopyFileToContainer and withCommand.

  • The Gradle build.

  • run-uat.sh, RunUatSmokeTest, docker-tool.sh test-uat and the tika-app and tika-eval checklists, which are summarised from the 4.1.x documentation and the 4.1.0 source tree.

  • The MockParser tests through tika-server (503 with TIMEOUT, OOM or UNSPECIFIED_CRASH).

  • Any security-regression fixture (XXE or similar).