Introduction

Approval Testing

Approval testing is a technique that allows you to compare the output of your code with a known good/previously approved output.

An approval test case will only succeed if the actual received output is equal to the previously approved output.

If the received output is different from the approved output, the test will fail and leave it to a human reviewer to approve the received output or to fix the code.

Approval testing is especially useful when:

  • You are testing complex objects or large data sets, where writing individual assertions for each property would be impractical and fragile.

  • You want to catch unintended changes in serialized output (JSON, XML, YAML, etc.).

  • You are testing against legacy code where the expected output is hard to describe with assertions but easy to verify visually.

  • You want to replace long chains of assertEquals calls with a single, readable golden master file.

Consider testing a method that returns a summary object:

OrderSummary record
public record OrderSummary(
    String orderId,
    String customerName,
    String shippingAddress,
    List<String> items,
    int itemCount,
    double subtotal,
    double tax,
    double total,
    String status) {}

With traditional assertions, you need to check each field individually — and it’s easy to miss one:

Java
OrderSummary order = createOrderSummary();

assertThat(order.orderId()).isEqualTo("ORD-12345");
assertThat(order.customerName()).isEqualTo("Jane Smith");
assertThat(order.shippingAddress()).isEqualTo("123 Main St, Springfield");
assertThat(order.items()).containsExactly("Widget A", "Gadget B", "Doohickey C");
assertThat(order.itemCount()).isEqualTo(3);
assertThat(order.subtotal()).isEqualTo(59.97);
assertThat(order.tax()).isEqualTo(4.80);
// missing: total (1)
assertThat(order.status()).isEqualTo("confirmed");
Kotlin
val order = createOrderSummary()

assertThat(order.orderId()).isEqualTo("ORD-12345")
assertThat(order.customerName()).isEqualTo("Jane Smith")
assertThat(order.shippingAddress()).isEqualTo("123 Main St, Springfield")
assertThat(order.items()).containsExactly("Widget A", "Gadget B", "Doohickey C")
assertThat(order.itemCount()).isEqualTo(3)
assertThat(order.subtotal()).isEqualTo(59.97)
assertThat(order.tax()).isEqualTo(4.80)
// missing: total (1)
assertThat(order.status()).isEqualTo("confirmed")
1 The total field is never checked — the compiler won’t catch this.

With approval testing, the entire object is captured automatically:

Java
OrderSummary order = createOrderSummary();

approve(order).printedAs(multiLineString()).byFile();
Kotlin
val order = createOrderSummary()

approve(order).printedAs(multiLineString()).byFile()

The approved file contains every field, making missing checks impossible:

Approved file
OrderSummary [
  orderId=ORD-12345,
  customerName=Jane Smith,
  shippingAddress=123 Main St, Springfield,
  items=[
    Widget A,
    Gadget B,
    Doohickey C
  ],
  itemCount=3,
  subtotal=59.97,
  tax=4.8,
  total=64.77,
  status=confirmed
]

ApproveJ

ApproveJ is a Java implementation of approval testing with a builder-based fluent API, several built-in tools, and optional extension points.

  • No dependencies in the core module — works with any JVM project.

  • Fluent builder API — chain printing, scrubbing, and approval in a single expression.

  • Extensible — bring your own print formats, scrubbers, and reviewers, or use the built-in ones for JSON, YAML, and HTTP.

  • IDE-friendly — approved files live next to your tests, with correct file extensions for syntax highlighting.

To review the code, file issues or suggest changes, please visit the project’s GitHub page.

Getting Started

Requirements

In order to use ApproveJ you need a JDK 21 or higher.

Dependencies

To use ApproveJ in your own project, you need to add it as a dependency.

Gradle
testImplementation 'org.approvej:core:1.4.1'
Gradle.kts
testImplementation("org.approvej:core:1.4.1")
Maven
<dependency>
  <groupId>org.approvej</groupId>
  <artifactId>core</artifactId>
  <version>1.4.1</version>
  <scope>test</scope>
</dependency>

Bill of Materials (BOM)

If you want to use more than one module in the same project, you can use ApproveJ’s bill of materials (BOM) and omit the explicit version for the other modules.

Gradle
implementation platform('org.approvej:bom:1.4.1')
implementation 'org.approvej:json-jackson'
Gradle.kts
implementation(platform("org.approvej:bom:1.4.1"))
implementation("org.approvej:json-jackson")
Maven
<project>
  <!--…-->
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.approvej</groupId>
        <artifactId>bom</artifactId>
        <version>1.4.1</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>
  <!-- … -->
  <dependencies>
    <dependency>
      <groupId>org.approvej</groupId>
      <artifactId>json-jackson</artifactId>
    </dependency>
  </dependencies>
  <!-- … -->
</project>

IDE Support

If you use IntelliJ IDEA, install the ApproveJ plugin for integrated diff viewing, one-click approval, and navigation between tests and approved files. See IntelliJ Plugin — IDE Integration for details.

Basics — Your First Approval Test

The general entry point to start an approval is the static initializer approve of the ApprovalBuilder. It takes the object which you want to approve as an argument and returns a builder to configure the approval with a fluent API.

Approve Strings

If you have anything that returns an arbitrary string, you can simply build an approval like this.

Java
String result = hello("World");

approve(result) (1)
    .byFile(); (2)
Kotlin
val result = hello("World")

approve(result) (1)
  .byFile() (2)
1 creates an ApprovalBuilder<String>
2 compares result to a previously approved value stored in a file next to the test and fails the test if the result differs

Executing such a test will create two files next to the test code file named like <TestClass>-<testMethod>-received.txt and <TestClass>-<testMethod>-approved.txt.

The received file will always contain a string representation of the object you want to approve at the last execution.

The approved file will be empty at first. You can use a diff tool of your choice to compare the two files and merge values that you want to approve. If the received value equals the content of the approved file, the received file will be deleted automatically.

Approved file
Hello, World!

You can adjust various details of this process:

Approve POJOs

Of course, you might want to approve more complex objects than just strings.

For example, a simple POJO like this.

Person POJO
public record Person(String name, LocalDate birthDate) {}

By default, ApproveJ will simply call the object’s toString method to turn the object into a string just before approving it.

Java
Person person = createPerson("John Doe", LocalDate.of(1990, 1, 1));

approve(person) (1)
    .byFile();
Kotlin
val person = createPerson("John Doe", LocalDate.of(1990, 1, 1))

approve(person) (1)
  .byFile()
1 creates an ApprovalBuilder<Person> to approve the person

Will approve the following value:

Approved file
Person[name=John Doe, birthDate=1990-01-01]

See Printing — Customize How Values Are Turned into Strings if you need a more sophisticated way of printing.

Named Approvals — Approve Multiple Values per Test Case

Optionally, you can assign a specific name for an approval. When you Approve by File the chosen name will be added to the filename to help identify the specific approval. It is also necessary to assign a name if there are multiple approvals per test case, as otherwise later approvals will overwrite earlier ones.

Java
Person jane = createPerson("Jane Doe", LocalDate.of(1990, 1, 1));
Person john = createPerson("John Doe", LocalDate.of(2012, 6, 2));

approve(jane).named("jane").byFile();
approve(john).named("john").byFile();
Kotlin
val jane = createPerson("Jane Doe", LocalDate.of(1990, 1, 1))
val john = createPerson("John Doe", LocalDate.of(2012, 6, 2))

approve(jane).named("jane").byFile()
approve(john).named("john").byFile()

This test generates two sets of files

  1. <TestClass>-<testMethod>-jane-<received/approved>.txt

  2. <TestClass>-<testMethod>-john-<received/approved>.txt

Printing — Customize How Values Are Turned into Strings

While some toString implementations already are quite good, they typically return a one-liner. This is fine as long as you only have a few properties. However, if you have a lot of properties, it is much easier to read the result if it is formatted nicely.

The most common way to change the print format is to configure a global default. For individual overrides, you can use the printedAs method with a PrintFormat, or the printedBy method with a Function<T, String>.

In addition to the printing method, a PrintFormat also provides a filename extension that will be used if the value is written to a file (see Approve by File).

Print Each Property on Its Own Line

ApproveJ provides a generic MultiLineStringPrintFormat that will print the object with each of its properties on a new line to make comparing the result easier. In addition to field-backed properties, it discovers getter-only properties via getXxx() and isXxx() methods (the latter only for boolean/Boolean return types, per the JavaBeans convention). Field-backed properties appear first in declaration order, followed by getter-only properties alphabetically.

To use this format for all approvals, configure it as the default:

defaultPrintFormat = multiLineString

Alternatively, apply it to a single approval via printedAs:

Java
Person person = createPerson("John Doe", LocalDate.of(1990, 1, 1));

approve(person)
    .printedAs(multiLineString()) (1)
    .byFile();
Kotlin
val person = createPerson("John Doe", LocalDate.of(1990, 1, 1))

approve(person)
  .printedAs(multiLineString()) (1)
  .byFile()
1 applies the MultiLineStringPrinter and returns a new ApprovalBuilder<String>

Now the approved file will look like this

Approved file
Person [
  name=John Doe,
  birthDate=1990-01-01
]

Use a Custom Function to Print

You can provide a custom Function<T, String> to the builder’s printedBy method.

Java
Person person = createPerson("John Doe", LocalDate.of(1990, 1, 1));

approve(person)
    .printedBy(it -> String.format("%s, born %s", it.name(), it.birthDate())) (1)
    .byFile();
Kotlin
val person = createPerson("John Doe", LocalDate.of(1990, 1, 1))

approve(person)
  .printedBy { "%s, born %s".format(it.name, it.birthDate) } (1)
  .byFile()
1 applies the given Function and returns a new ApprovalBuilder<String>

So the content of the approved file will look like this

Approved file
John Doe, born 1990-01-01

Implement a Reusable PrintFormat

For more complex cases, you can implement your own PrintFormat.

This will allow you to also override the filenameExtension method. If you use a FileApprover (see Approve by File), it will be used to determine the files' filename extension. This is useful if your Printer creates a certain format (e.g. JSON, XML, YAML, …).

E.g. the following format will print a Person as a YAML string.

Java
public class PersonYamlPrintFormat implements PrintFormat<Person> {
  @Override
  public Printer<Person> printer() {
    return (Person person) ->
        """
        person:
          name: "%s"
          birthDate: "%s"
        """
            .formatted(person.name(), person.birthDate());
  }

  @Override
  public String filenameExtension() {
    return "yaml";
  }
}
Kotlin
class PersonYamlPrinter : PrintFormat<Person> {
  override fun printer() =
    Printer<Person> { person ->
      """
    person:
      name: "${person.name}"
      birthDate: "${person.birthDate}"
    """
        .trimIndent()
    }

  override fun filenameExtension() = "yaml"
}

The resulting file will look like this

Approved file
person:
  name: "John Doe"
  birthDate: "1990-01-01"

Configure the Default Print Format Globally

Instead of calling printedAs on every approval, you can set a project-wide default in your approvej.properties:

defaultPrintFormat = multiLineString

Any built-in alias (singleLineString, multiLineString, json, yaml) or a fully-qualified class name works here. See Configuration — Global Defaults and Environment Settings for all available configuration sources and options.

Scrubbing — Make Random Parts Static

Sometimes you might not be able to control the exact output of the object you want to approve. For example, if the result object contains a timestamp or a generated ID, you might want to ignore these for the approval.

If you can control the test data, you may prefer to use fixed values (e.g. a constant UUID or a frozen clock) instead of scrubbing. Scrubbing is the right choice when the dynamic data comes from code you do not control, or when fixing the data would make the test less realistic.

You can do this by using the scrubbedOf method of the ApprovalBuilder and provide a Scrubber/UnaryOperator<T> implementation.

Most built-in scrubbers are Scrubber<String> implementations — they work on the string representation, not on the original object. If you are approving a POJO, you need to print first so the scrubbers can match against the resulting text. Call printed() to apply the configured default print format, or use printedAs()/printedBy() to choose one explicitly.

For instance, in the following BlogPost POJO there are two generated fields:

Java
class BlogPost {
  private final UUID id;
  private final String title;
  private final String content;
  private final Instant published;

  public BlogPost(String title, String content) {
    this.id = UUID.randomUUID(); (1)
    this.title = title;
    this.content = content;
    this.published = Instant.now(); (2)
  }

  public String title() {
    return title;
  }

  public String content() {
    return content;
  }

  public Instant published() {
    return published;
  }

  public UUID id() {
    return id;
  }

  @Override
  public String toString() {
    return "BlogPost[title=%s, content=%s, published=%s, id=%s]"
        .formatted(title, content, published, id);
  }
}
1 the id is a UUID that’s being generated randomly, and
2 the published is a LocalDateTime set to now.

In the following example, the two dynamic properties are scrubbed with the built-in Scrubbers for dateTimeFormat and uuids.

Java
var blogPost =
    createBlogPost("Latest News", "Lorem ipsum dolor sit amet, consectetur adipiscing elit.");

approve(blogPost)
    .printedAs(multiLineString())
    .scrubbedOf(dateTimeFormat("yyyy-MM-dd'T'HH:mm:ss.SSSX")) (1)
    .scrubbedOf(uuids()) (2)
    .byFile(); (3)
Kotlin
val blogPost =
  createBlogPost("Latest News", "Lorem ipsum dolor sit amet, consectetur adipiscing elit.")

approve(blogPost)
  .printedAs(multiLineString())
  .scrubbedOf(dateTimeFormat("yyyy-MM-dd'T'HH:mm:ss.SSSX")) (1)
  .scrubbedOf(uuids()) (2)
  .byFile()
1 replaces the published date with a numbered placeholder
2 replaces the id UUID with a numbered placeholder
3 so that the approved result looks like this
Approved file
BlogPost [
  id=[uuid 1],
  title=Latest News,
  content=Lorem ipsum dolor sit amet, consectetur adipiscing elit.,
  published=[datetime 1]
]

Available Built-in Scrubbers

All built-in Scrubber implementations are available via the Scrubbers utility class.

String Scrubbers

Factory Method Description Example Match

strings(String first, String…​ more)

Scrubs exact occurrences of the given strings. Useful when dynamic values are known upfront (e.g. from test setup).

strings("Jane") matches Jane

stringsMatching(String pattern) / stringsMatching(Pattern pattern)

Scrubs all substrings matching a regular expression.

stringsMatching("\\d{5}") matches 12345

uuids()

Scrubs UUID strings.

550e8400-e29b-41d4-a716-446655440000

Date/Time Scrubbers

All date/time scrubbers use a DateTimeFormatter pattern internally to generate a matching regex. Use dateTimeFormat(pattern) for custom patterns, or one of the pre-configured methods below.

Factory Method Description Example Match

dateTimeFormat(String pattern)

Scrubs date/time strings matching a DateTimeFormatter pattern.

dateTimeFormat("dd.MM.yyyy") matches 25.02.2019

dateTimeFormat(String pattern, Locale locale)

Same as above with a specific locale for locale-sensitive patterns (e.g. month names).

dateTimeFormat("d MMM yyyy", Locale.US) matches 25 Feb 2019

isoLocalDates()

Scrubs ISO local dates (yyyy-MM-dd).

2019-02-25

isoOffsetDates()

Scrubs ISO dates with UTC offset (yyyy-MM-ddXXX).

2019-02-25+02:00

isoDates()

Scrubs ISO dates with optional offset.

2019-02-25 or 2019-02-25+02:00

isoLocalTimes()

Scrubs ISO local times with optional fractional seconds.

12:34:56 or 12:34:56.123

isoOffsetTimes()

Scrubs ISO times with UTC offset.

12:34:56+02:00

isoTimes()

Scrubs ISO times with optional offset.

12:34:56 or 12:34:56.123+02:00

isoLocalDateTimes()

Scrubs ISO local date-times.

2019-02-25T12:34:56

isoOffsetDateTimes()

Scrubs ISO date-times with UTC offset.

2019-02-25T12:34:56+02:00

isoZonedDateTimes()

Scrubs ISO date-times with offset and time zone ID.

2019-02-25T12:34:56+02:00[Europe/Berlin]

isoDateTimes()

Scrubs ISO date-times with optional offset and time zone. Matches all of the above date-time variants.

2019-02-25T12:34:56

isoInstants()

Scrubs ISO instants (UTC timestamps).

2019-02-25T12:34:56.789Z

isoOrdinalDates()

Scrubs ISO ordinal dates (day of year).

2019-056

isoWeekDates()

Scrubs ISO week dates.

2019-W09-1

basicIsoDates()

Scrubs basic ISO dates without separators.

20190225

rfc1123DateTimes()

Scrubs RFC 1123 date-times (HTTP headers). Always uses Locale.US.

Mon, 25 Feb 2019 12:34:56 GMT

Field Scrubbers

Factory Method Description

field(Class<T> type, String fieldName)

Replaces the value of a named field with null on objects of the given type. Works on the original object before printing — use with scrubbedOf before printedAs.

Extension Scrubbers

The following scrubbers are provided by extension modules. See Extensions — Format-Specific Printing and Scrubbing for details and dependency coordinates.

Factory Method Description

JsonPointerScrubber.jsonPointer(String pointer)

Scrubs a JSON node identified by a JSON Pointer path (e.g. "/id"). Useful when only some values of a matching pattern need scrubbing.

HttpScrubbers.headerValue(String headerName)

Scrubs the value of an HTTP header by name.

HttpScrubbers.hostHeaderValue()

Scrubs the Host header value (varies by port).

HttpScrubbers.userAgentHeaderValue()

Scrubs the User-agent header value (varies by JVM version).

Replacements — Control What Scrubbed Values Become

By default, built-in scrubbers replace each match with a numbered placeholder like [uuid 1], [datetime 1]. If the same value appears more than once, it gets the same number, so you can still see that two scrubbed values were equal.

You can change this behavior by calling the replacement method on a scrubber to choose a different Replacement function.

Java
var blogPost =
    createBlogPost("Latest News", "Lorem ipsum dolor sit amet, consectetur adipiscing elit.");

approve(blogPost)
    .printedAs(multiLineString())
    .scrubbedOf(uuids().replacement(labeled("id"))) (1)
    .scrubbedOf(dateTimeFormat("yyyy-MM-dd'T'HH:mm:ss.SSSX").replacement(masking())) (2)
    .byFile();
Kotlin
val blogPost =
  createBlogPost("Latest News", "Lorem ipsum dolor sit amet, consectetur adipiscing elit.")

approve(blogPost)
  .printedAs(multiLineString())
  .scrubbedOf(uuids().replacement(labeled("id"))) (1)
  .scrubbedOf(dateTimeFormat("yyyy-MM-dd'T'HH:mm:ss.SSSX").replacement(masking())) (2)
  .byFile()
1 replaces the UUID with a fixed label [id] instead of the default [uuid 1]
2 masks the date-time, replacing each digit with 1 and each letter with a or A
Approved file
BlogPost [
  id=[id],
  title=Latest News,
  content=Lorem ipsum dolor sit amet, consectetur adipiscing elit.,
  published=1111-11-11A11:11:11.111111A
]

The following replacement functions are available via the Replacements utility class:

Factory Method Description Example Output

numbered(String label)

Replaces with [label ] where is the number of the distinct match. This is the default for built-in scrubbers.

[uuid 1], [uuid 2]

numbered()

Same as numbered("scrubbed").

[scrubbed 1], [scrubbed 2]

labeled(String label)

Replaces every match with the same fixed label [label], regardless of how many distinct values are found.

[id]

string(String replacement)

Replaces every match with the given static string.

"*"

relativeDate()

Replaces date matches with a human-readable relative duration. Only available on date/time scrubbers.

[today], [yesterday], [in 13 days]

relativeDateTime()

Replaces date-time matches with a human-readable relative duration including time. Only available on date/time scrubbers.

[now], [10s ago], [in 1d 23h 59m 59s]

masking()

Masks each character: uppercase letters become A, lowercase become a, digits become 1. Preserves the structure of the original value. Good for fixed-length strings like order numbers or date patterns.

"ORD-123""AAA-111"

You can also implement a custom Replacement<String> as a lambda. It receives the matched string and a count (the number of the distinct match) and returns the replacement.

Build Your Own Scrubber

The RegexScrubber already allows for a lot of special custom cases. In case this isn’t enough, you can also provide a custom Scrubber<T>/UnaryOperator<T> implementation to the builder’s scrubbedOf method.

Java
Contact contact = createContact("Jane Doe", "jane@approvej.org", "+1 123 456 7890");
approve(contact)
    .scrubbedOf(it -> new Contact(-1, it.name(), it.email(), it.phoneNumber())) (1)
    .printedAs(multiLineString())
    .byFile();
Kotlin
val contact = createContact("Jane Doe", "jane@approvej.org", "+1 123 456 7890")

approve(contact)
  .scrubbedOf { Contact(-1, it.name, it.email, it.phoneNumber) } (1)
  .printedAs(multiLineString())
  .byFile()
1 this custom Scrubber specifically replaces the number property of the Contact with a constant
Approved file
Contact [
  number=-1,
  name=Jane Doe,
  email=jane@approvej.org,
  phoneNumber=+1 123 456 7890
]

Note that this Scrubber is a Scrubber<Contact> and not a Scrubber<String>. Hence, it is necessary to apply it before the Printer is applied.

If you want to reuse a scrubber across tests, you can implement the Scrubber interface as a standalone class. See Custom Scrubber for a full walkthrough.

Approving — Adjust the Verification

You conclude the ApprovalBuilder by specifying by which Approver the received value should be approved.

Approve Inline Without Files

The ApprovalBuilder.byValue() method will use an InplaceApprover to approve the received value by comparison with a directly provided previously approved value. That way, the approved value is plainly visible in the test code. However, this might not be practical for large objects. It also does not allow to use a diff tool to compare the result with the previously approved value.

Java
Person person = createPerson("John Doe", LocalDate.of(1990, 1, 1));

approve(person).byValue("Person[name=John Doe, birthDate=1990-01-01]");
Kotlin
val person = createPerson("John Doe", LocalDate.of(1990, 1, 1))

approve(person).byValue("Person[name=John Doe, birthDate=1990-01-01]")

Approve by File

The ApprovalBuilder.byFile() method will use a FileApprover to approve the received value by comparison with a previously approved value stored in a file. It is used in most of the examples above.

If no approved file exists, it will be created as an empty approved file. The received value will be written to another received file.

If there are approved files with the correct base name, but a different filename extension, these will be automatically deleted. The most recently modified one is renamed with the correct filename extension, if no file of that name exists yet.

If the approved file exists, it will be compared with the received value. If they are equal, the test will pass. Any existing received file will be deleted automatically in that case.

If the files are not equal, the test will fail. The received value will be persisted in a received file. Any existing received value will be overwritten by this.

You can use a diff tool of your choice to compare the two files and merge values that you want to approve.

Next to Test

By default, the FileApprover will put the received and approved files next to the test class and name them like the test case method.

You can make this explicit by using the PathProviderBuilder.nextToTest() method.

Java
Person person = createPerson("John Doe", LocalDate.of(1990, 1, 1));

approve(person).byFile(nextToTest()); (1)
Kotlin
val person = createPerson("John Doe", LocalDate.of(1990, 1, 1))

approve(person).byFile(nextToTest()) (1)
1 defines the PathProvider explicitly, same as just calling byFile()
File structure
.
└── 📁src/test/java/…
    ├── 📄 <TestClass>.java
    ├── 📄 <TestClass>-<testMethod>-approved.txt
    └── 📄 <TestClass>-<testMethod>-received.txt

Custom Filename Extension

The PathProviderBuilder.filenameExtension method gives you the opportunity to use a different file extension for the approved and received files.

Most of the time you probably want to do this because you’re using a special printer that creates a specific format (e.g. JSON, XML, YAML, …). In that case, you might want to provide a Implement a Reusable PrintFormat and override the filenameExtension method of the Printer instead of changing the filename extension here.
Java
Person person = createPerson("John Doe", LocalDate.of(1990, 1, 1));

approve(person)
    .printedBy(
        it ->
            """
            person:
              name: "%s"
              birthDate: "%s"
            """
                .formatted(it.name(), it.birthDate())) (1)
    .byFile(nextToTest().filenameExtension("yml"));
Kotlin
val person = createPerson("John Doe", LocalDate.of(1990, 1, 1))

approve(person)
  .printedBy {
    """
    person:
      name: "${it.name}"
      birthDate: "${it.birthDate}"
    """
      .trimIndent()
  } (1)
  .byFile(nextToTest().filenameExtension("yml")) (2)
1 this printer will create a YAML version of the object
2 so it makes sense to change the filename extension, so your IDE will apply appropriate syntax highlighting
File structure with custom filename extension
.
└── 📁src/test/java/…
    ├── 📄 <TestClass>.java
    ├── 📄 <TestClass>-<testMethod>-approved.<filenameExtension>
    └── 📄 <TestClass>-<testMethod>-received.<filenameExtension>

In a Subdirectory

If you have test classes with a lot of approval tests, there are quite a lot of files created next to the test class. In that case, you can use the PathProviderBuilder.nextToTestInSubdirectory to put all the files in a subdirectory named after the test class.

Java
Person person = createPerson("John Doe", LocalDate.of(1990, 1, 1));

approve(person).printedAs(new PersonYamlPrintFormat()).byFile(nextToTestInSubdirectory());
Kotlin
val person = createPerson("John Doe", LocalDate.of(1990, 1, 1))

approve(person).printedAs(PersonYamlPrinter()).byFile(nextToTestInSubdirectory())
File structure with subdirectory
.
└── 📁src/test/java/…
    ├── 📁 <TestClass>
    │   ├── 📄 <testMethod>-approved.txt
    │   └── 📄 <testMethod>-received.txt
    └── 📄 <TestClass>.java

Given Path

Alternatively, you can simply specify the path of the approved file. If the given approved file path contains the word approved just before the filename extension, it will be replaced with received to determine the received file path. Otherwise, the word received will be added at the end of the filename.

For example

  • src/test/resources/ApprovingDocTest-approve_file_approved_path-approved.yamlsrc/test/resources/ApprovingDocTest-approve_file_approved_path-received.yaml

  • src/test/resources/ApprovingDocTest-approve_file_approved_path.yamlsrc/test/resources/ApprovingDocTest-approve_file_approved_path-received.yaml.

Java
Person person = createPerson("John Doe", LocalDate.of(1990, 1, 1));

approve(person)
    .printedAs(new PersonYamlPrintFormat())
    .byFile("src/test/resources/BasicExamples-approve file approved path.yaml"); (1)
Kotlin
val person = createPerson("John Doe", LocalDate.of(1990, 1, 1))

approve(person)
  .printedAs(PersonYamlPrinter())
  .byFile("src/test/resources/BasicExamples-approve file approved path.yaml") (1)
1 this will expect the approved file at this path, the received file will be created next to it at src/test/resources/ApprovingDocTest-approve_file_approved_path-received.yaml
File structure with given path
.
└── 📁src/test/java/…
│   └── 📄 <TestClass>.java
└── 📁src/test/resources
    ├── 📄 src/test/resources/BasicExamples-approve_file_approved_path.yaml
    └── 📄 src/test/resources/BasicExamples-approve_file_approved_path-received.yaml

Custom PathProvider/PathProviderBuilder

You can also define your own PathProvider and pass it to the byFile method.

Or you can create a method that returns a PathProviderBuilder and pass it to the byFile method. That way the filename extension of the used Printer is set just before approval.

In that case, you might want to take advantage of the StackTraceTestFinderUtil class to find the test source path or the current test method based on the current stack trace.

Catch Forgotten Approvals

If you call approve() without a concluding terminal method (by(), byFile(), or byValue()), the test will silently pass without any approval actually happening. To catch this mistake, you can annotate your test class with @ApprovalTest.

Java
@org.approvej.ApprovalTest
class MyTest {
  // ...
}
Kotlin
@org.approvej.ApprovalTest
class MyTest {
  // ...
}

This registers a JUnit Jupiter extension that checks after each test method whether all approve() calls were concluded. If any were not, the test fails with a DanglingApprovalError.

The extension is safe to use with parallel test execution.

Reviewing — Check Differences

If the received value differs from the previously approved, ApproveJ will by default simply fail the test. You then need to review the differences and decide if these are to be approved or actually were not intended.

Without a reviewer, you would have to locate the received and approved files yourself, open them side by side, and merge the changes manually. A configured reviewer automates this: it opens your diff tool of choice directly on the right files, giving you a faster feedback loop and letting you approve changes without leaving the test run.

Blocking/Non-Blocking Review

Some reviewers are blocking. They trigger the diff/merge tool and wait for you to close it again. This gives you the opportunity to merge the content of the two files before the test finishes, so the test does not fail due to your given approval.

Other implementations are non-blocking. These simply display the differences between the two files, but will fail the test immediately. So, after you merged the files or fixed the code, you need to run the test again.

Configure a Reviewer Script

The most common way to set up reviewing is to configure a review script in your approvej.properties. Since each developer on a team may prefer a different diff tool, the best place for this is the user-level configuration file:

~/.config/approvej/approvej.properties
defaultFileReviewerScript = idea diff --wait "{receivedFile}" "{approvedFile}"

The script can contain the following placeholders:

  • {receivedFile} — will be replaced with the path to the received file

  • {approvedFile} — will be replaced with the path to the approved file

Use --wait or similar flags if your diff tool supports them to make the review blocking. This allows you to approve changes before the test finishes.

Here are configuration examples for common diff tools:

IntelliJ IDEA (blocking)
defaultFileReviewerScript = idea diff --wait "{receivedFile}" "{approvedFile}"
The ApproveJ IntelliJ plugin lets you view diffs and approve received files directly in the IDE — handy when you want to review changes without re-running the test to trigger the reviewer script.
VS Code (blocking)
defaultFileReviewerScript = code --wait --diff "{receivedFile}" "{approvedFile}"
Beyond Compare (blocking, macOS)
defaultFileReviewerScript = bcomp -wait "{receivedFile}" "{approvedFile}"
Meld (blocking)
defaultFileReviewerScript = meld "{receivedFile}" "{approvedFile}"
vimdiff (blocking)
defaultFileReviewerScript = vimdiff "{receivedFile}" "{approvedFile}"

You can also set this per project in src/test/resources/approvej.properties or via an environment variable (see Configuration — Global Defaults and Environment Settings).

Override the Reviewer for a Single Test

In rare cases you may want to use a different reviewer for a specific test. You can do so by calling the reviewedBy method on the ApprovalBuilder.

Java
Person person = createPerson("John Doe", LocalDate.of(1990, 1, 1));

approve(person)
    .printedAs(new PersonYamlPrintFormat())
    .reviewedBy("idea diff {receivedFile} {approvedFile}") (1)
    .byFile(); (2)
Kotlin
val person = createPerson("John Doe", LocalDate.of(1990, 1, 1))

approve(person)
  .printedAs(PersonYamlPrinter())
  .reviewedBy("meld \"{receivedFile}\" \"{approvedFile}\"") (1)
  .byFile() (2)
1 sets the given script to be executed to support the review
2 executes the review script if the received value differs from the approved value

Automatic Review

If you have a lot of approvals that need to be updated, you might want to just accept all the updates automatically. In that case, you can use the automatic FileReviewer. It simply writes the received value into the approved file without checking them at all.

This will overwrite your approved file(s). You will probably want to check the changed files before committing them to version control!
Java
Person person = createPerson("John Doe", LocalDate.of(1990, 1, 1));

approve(person)
    .printedAs(new PersonYamlPrintFormat())
    .reviewedBy(Reviewers.automatic())
    .byFile();
Kotlin
val person = createPerson("John Doe", LocalDate.of(1990, 1, 1))

approve(person).printedAs(PersonYamlPrinter()).reviewedBy(Reviewers.automatic()).byFile()

You can also set this globally via configuration:

defaultFileReviewer = automatic

Extensions — Format-Specific Printing and Scrubbing

ApproveJ’s core module has no external dependencies. For integration with popular libraries, ApproveJ provides optional extension modules that add format-specific printing, scrubbing, and more.

JSON with Jackson

ApproveJ provides JSON support via Jackson in two modules:

  • json-jackson for Jackson 2.x

  • json-jackson3 for Jackson 3.x

Both modules provide the same functionality with identical APIs. Choose the one matching your project’s Jackson version.

Use only one of these modules in your project, not both. Both modules register with the same alias "json", and having both on the classpath will cause an error at runtime.

Since Jackson is a provided dependency, you need to add it explicitly.

Dependencies

Jackson 2.x
Gradle
implementation 'org.approvej:json-jackson:1.4.1'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.0'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.0'
Gradle.kts
implementation("org.approvej:json-jackson:1.4.1")
implementation("com.fasterxml.jackson.core:jackson-databind:2.18.0")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.0")
Maven
<dependency>
  <groupId>org.approvej</groupId>
  <artifactId>json-jackson</artifactId>
  <version>1.4.1</version>
</dependency>
<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
  <version>2.18.0</version>
</dependency>
<dependency>
  <groupId>com.fasterxml.jackson.datatype</groupId>
  <artifactId>jackson-datatype-jsr310</artifactId>
  <version>2.18.0</version>
</dependency>
Jackson 3.x
Gradle
implementation 'org.approvej:json-jackson3:1.4.1'
implementation 'tools.jackson.core:jackson-databind:3.0.0'
Gradle.kts
implementation("org.approvej:json-jackson3:1.4.1")
implementation("tools.jackson.core:jackson-databind:3.0.0")
Maven
<dependency>
  <groupId>org.approvej</groupId>
  <artifactId>json-jackson3</artifactId>
  <version>1.4.1</version>
</dependency>
<dependency>
  <groupId>tools.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
  <version>3.0.0</version>
</dependency>
If you use the ApproveJ BOM, you can omit the Jackson version numbers as the BOM provides recommended versions.
Jackson 3 has Java date/time support built-in, so no separate jackson-datatype-jsr310 dependency is needed.

The JSON module provides a JsonPrintFormat (see Printing — Customize How Values Are Turned into Strings) and a JsonPointerScrubber (see Scrubbing — Make Random Parts Static) for working with JSON data in the approval flow.

Import Differences

The API is identical between both modules. Only the import statements differ:

Jackson 2.x Jackson 3.x

org.approvej.json.jackson.*

org.approvej.json.jackson3.*

com.fasterxml.jackson.databind.*

tools.jackson.databind.*

Print as JSON

The JsonPrintFormat serializes any object as pretty-printed JSON using Jackson. This is the most common way to use the JSON module — pass any POJO, record, or collection and get a readable, diffable JSON representation.

To use JSON as the default print format for all approvals, configure it globally:

defaultPrintFormat = json

Alternatively, apply it to a single approval via printedAs:

Java
Person person = createPerson("John Doe", LocalDate.of(1990, 1, 1));

approve(person)
    .printedAs(json()) (1)
    .byFile();
Kotlin
val person = createPerson("John Doe", LocalDate.of(1990, 1, 1))

approve(person)
  .printedAs(json()) (1)
  .byFile()
1 applies the JsonPrintFormat to serialize the Person object as JSON
Approved file
{
  "name" : "John Doe",
  "birthDate" : "1990-01-01"
}

Pretty Print a JSON String

If you already have a JSON string (e.g. from an API response), JsonPrintFormat automatically pretty prints it. The only downside is that you cannot use JSON-specific scrubbers like JsonPointerScrubber.

Java
String createdBlogPostJson =
    createTaggedBlogPost(
        "Latest News",
        "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
        List.of(NEWS, ENTERTAINMENT));

approve(createdBlogPostJson)
    .scrubbedOf(uuids())
    .scrubbedOf(dateTimeFormat("yyyy-MM-dd'T'HH:mm:ss.SSSX"))
    .printedAs(json()) (1)
    .byFile();
Kotlin
val createdBlogPostJson =
  createTaggedBlogPost(
    "Latest News",
    "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
    listOf(NEWS, ENTERTAINMENT),
  )

approve(createdBlogPostJson)
  .scrubbedOf(uuids())
  .scrubbedOf(dateTimeFormat("yyyy-MM-dd'T'HH:mm:ss.SSSX"))
  .printedAs(json()) (1)
  .byFile()
1 applies the JsonPrintFormat to pretty print the given JSON string
Approved file
{
  "id" : "[uuid 1]",
  "title" : "Latest News",
  "content" : "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
  "tagIds" : [ "[uuid 2]", "[uuid 3]" ],
  "published" : "[datetime 1]"
}

Scrub Specific JSON Fields

The JsonPointerScrubber can be used to scrub a JSON node identified by a JsonPointer.

Compared to generic Scrubber<String> implementations this is particularly useful when there are several values matching the same pattern, but only one of them needs to be scrubbed. For instance, if you have a JSON containing two UUIDs, one that was generated by the code (and hence needs to be scrubbed) and one that is a reference to another resource and should not be scrubbed.

To use the JsonPointerScrubber, first parse the JSON string into a JsonNode, then apply the scrubber. Combine it with printedAs(json()) to get a readable pretty-printed result.

Java
String createdBlogPostJson =
    createTaggedBlogPost(
        "Latest News",
        "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
        List.of(NEWS, ENTERTAINMENT));

approve(jsonMapper.readTree(createdBlogPostJson))
    .scrubbedOf(jsonPointer("/id").replacement("[scrubbed id]"))
    .scrubbedOf(jsonPointer("/published").replacement("[scrubbed published]"))
    .printedAs(json()) (1)
    .byFile();
Kotlin
val createdBlogPostJson =
  createTaggedBlogPost(
    "Latest News",
    "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
    listOf(NEWS, ENTERTAINMENT),
  )

approve(jsonMapper.readTree(createdBlogPostJson))
  .scrubbedOf(jsonPointer("/id").replacement("[scrubbed id]"))
  .scrubbedOf(jsonPointer("/published").replacement("[scrubbed published]"))
  .printedAs(json()) (1)
  .byFile()
1 applies the JsonPrintFormat for a readable approved file
Approved file
{
  "id" : "[scrubbed id]",
  "title" : "Latest News",
  "content" : "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
  "tagIds" : [ "00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002" ],
  "published" : "[scrubbed published]"
}

YAML with Jackson

ApproveJ provides YAML support via Jackson in two modules:

  • yaml-jackson for Jackson 2.x

  • yaml-jackson3 for Jackson 3.x

Both modules provide the same functionality with identical APIs. Choose the one matching your project’s Jackson version.

Use only one of these modules in your project, not both. Both modules register with the same alias "yaml", and having both on the classpath will cause an error at runtime.

Since Jackson is a provided dependency, you need to add it explicitly.

Dependencies

Jackson 2.x
Gradle
implementation 'org.approvej:yaml-jackson:1.4.1'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.0'
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.0'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.0'
Gradle.kts
implementation("org.approvej:yaml-jackson:1.4.1")
implementation("com.fasterxml.jackson.core:jackson-databind:2.18.0")
implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.18.0")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.0")
Maven
<dependency>
  <groupId>org.approvej</groupId>
  <artifactId>yaml-jackson</artifactId>
  <version>1.4.1</version>
</dependency>
<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
  <version>2.18.0</version>
</dependency>
<dependency>
  <groupId>com.fasterxml.jackson.dataformat</groupId>
  <artifactId>jackson-dataformat-yaml</artifactId>
  <version>2.18.0</version>
</dependency>
<dependency>
  <groupId>com.fasterxml.jackson.datatype</groupId>
  <artifactId>jackson-datatype-jsr310</artifactId>
  <version>2.18.0</version>
</dependency>
Jackson 3.x
Gradle
implementation 'org.approvej:yaml-jackson3:1.4.1'
implementation 'tools.jackson.core:jackson-databind:3.0.0'
implementation 'tools.jackson.dataformat:jackson-dataformat-yaml:3.0.0'
Gradle.kts
implementation("org.approvej:yaml-jackson3:1.4.1")
implementation("tools.jackson.core:jackson-databind:3.0.0")
implementation("tools.jackson.dataformat:jackson-dataformat-yaml:3.0.0")
Maven
<dependency>
  <groupId>org.approvej</groupId>
  <artifactId>yaml-jackson3</artifactId>
  <version>1.4.1</version>
</dependency>
<dependency>
  <groupId>tools.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
  <version>3.0.0</version>
</dependency>
<dependency>
  <groupId>tools.jackson.dataformat</groupId>
  <artifactId>jackson-dataformat-yaml</artifactId>
  <version>3.0.0</version>
</dependency>
If you use the ApproveJ BOM, you can omit the Jackson version numbers as the BOM provides recommended versions.
Jackson 3 has Java date/time support built-in, so no separate jackson-datatype-jsr310 dependency is needed.

The YAML module provides a YamlPrintFormat (see Printing — Customize How Values Are Turned into Strings) for rendering objects as YAML in the approval flow.

Import Differences

The API is identical between both modules. Only the import statements differ:

Jackson 2.x Jackson 3.x

org.approvej.yaml.jackson.*

org.approvej.yaml.jackson3.*

com.fasterxml.jackson.dataformat.yaml.*

tools.jackson.dataformat.yaml.*

Print as YAML

The YamlPrintFormat allows you to print any object in YAML format.

To use YAML as the default print format for all approvals, configure it globally:

defaultPrintFormat = yaml

Alternatively, apply it to a single approval via printedAs:

Java
Person person = createPerson("John Doe", LocalDate.of(1990, 1, 1));

approve(person)
    .printedAs(yaml()) (1)
    .byFile();
Kotlin
val person = createPerson("John Doe", LocalDate.of(1990, 1, 1))

approve(person)
  .printedAs(yaml()) (1)
  .byFile()
1 applies the YamlPrintFormat to convert the Person object to a string

Creates the following approved file:

Approved file
---
name: "John Doe"
birthDate: "1990-01-01"

HTTP — Catch Integration Risks

Unlike the JSON and YAML extensions, which provide print formats for specific data formats, the HTTP extension addresses a different concern: integration risk.

When your application calls an external HTTP API, a code change can silently alter the requests you send — a different path, a missing header, a changed body format. These issues often go unnoticed until they break in production. The HTTP extension lets you write simple contract tests that approve the exact HTTP requests your code sends. If a request changes, the approval test fails and shows you exactly what changed.

Dependencies

There are two ways to capture outgoing HTTP requests. Choose the one that fits your test setup.

HttpStubServer

A lightweight stub server included in the http module, with no additional dependencies.

Gradle
implementation 'org.approvej:http:1.4.1'
Gradle.kts
implementation("org.approvej:http:1.4.1")
Maven
<dependency>
  <groupId>org.approvej</groupId>
  <artifactId>http</artifactId>
  <version>1.4.1</version>
</dependency>
WireMock Adapter

If you already use WireMock in your tests, the http-wiremock module provides a utility to convert WireMock requests for approval.

Gradle
implementation 'org.approvej:http-wiremock:1.4.1'
implementation 'org.wiremock:wiremock:3.10.0'
Gradle.kts
implementation("org.approvej:http-wiremock:1.4.1")
implementation("org.wiremock:wiremock:3.10.0")
Maven
<dependency>
  <groupId>org.approvej</groupId>
  <artifactId>http</artifactId>
  <version>1.4.1</version>
</dependency>
<dependency>
  <groupId>org.approvej</groupId>
  <artifactId>http-wiremock</artifactId>
  <version>1.4.1</version>
</dependency>
<dependency>
  <groupId>org.wiremock</groupId>
  <artifactId>wiremock</artifactId>
  <version>3.10.0</version>
</dependency>

Capture Outgoing Requests

To approve the HTTP requests your code sends, you need to intercept them. Point your component at a stub server instead of the real API, then retrieve and approve the captured requests.

Using HttpStubServer

The HttpStubServer is a simple HTTP server based on the JVM’s built-in HttpServer. It automatically records all received requests and replies with a configurable response.

Initialize the server and configure your component to use its address:

Java
@AutoClose
private static final HttpStubServer cheeeperStub =
    new HttpStubServer().nextResponse(response().body("2999").statusCode(200));

@AutoClose
private static final HttpStubServer prycyStub =
    new HttpStubServer().nextResponse(response().body("3199").statusCode(200));
Kotlin
@AutoClose
private val cheeeperStub =
  HttpStubServer().nextResponse(response().body("2999").statusCode(200))

@AutoClose
private val prycyStub = HttpStubServer().nextResponse(response().body("3199").statusCode(200))

Then call your component and approve the captured requests:

Java
PriceComparator priceComparator =
    new PriceComparator(
        new CheeeperVendor(cheeeperStub.address()),
        new PrycyVendor(prycyStub.address(), "secret token"));

List<LookupResult> lookupResults = priceComparator.lookupPrice("1234567890123");

assertThat(lookupResults).hasSize(2);

approve(cheeeperStub.lastReceivedRequest())
    .named("cheeper")
    .scrubbedOf(hostHeaderValue())
    .scrubbedOf(headerValue("User-agent"))
    .printedAs(httpRequest())
    .byFile();
approve(prycyStub.lastReceivedRequest())
    .named("prycy")
    .scrubbedOf(hostHeaderValue())
    .scrubbedOf(headerValue("User-agent"))
    .printedAs(httpRequest())
    .byFile();
Kotlin
val priceComparator =
  PriceComparator(
    CheeeperVendor(cheeeperStub.address()),
    PrycyVendor(prycyStub.address(), "secret token"),
  )

val lookupResults = priceComparator.lookupPrice("1234567890123")

assertThat(lookupResults).hasSize(2)

approve(cheeeperStub.lastReceivedRequest())
  .named("cheeeper")
  .scrubbedOf(hostHeaderValue())
  .scrubbedOf(userAgentHeaderValue())
  .printedAs(httpRequest())
  .byFile()
approve(prycyStub.lastReceivedRequest())
  .named("prycy")
  .scrubbedOf(hostHeaderValue())
  .scrubbedOf(userAgentHeaderValue())
  .printedAs(httpRequest())
  .byFile()
Using WireMock

Use WireMockRequests.toReceivedHttpRequest() to convert WireMock’s Request objects to ReceivedHttpRequest:

import static org.approvej.http.wiremock.WireMockRequests.toReceivedHttpRequest;
import static org.approvej.http.ReceivedHttpRequestPrintFormat.httpRequest;
import static org.approvej.ApprovalBuilder.approve;

// Get the request from WireMock
Request request = wireMockServer.getAllServeEvents().getFirst().getRequest();

// Convert and approve
approve(toReceivedHttpRequest(request))
    .scrubbedOf(hostHeaderValue())
    .printedAs(httpRequest())
    .byFile();

To approve multiple requests:

wireMockServer.getAllServeEvents().stream()
    .map(event -> toReceivedHttpRequest(event.getRequest()))
    .forEach(request -> approve(request).named(request.uri().getPath()).byFile());
WireMock’s getAllServeEvents() returns events in reverse chronological order (most recent first). If you need chronological order, reverse the list.

Print as HTTP Request Files

The ReceivedHttpRequestPrintFormat prints captured requests in the HTTP request file format. This format is human-readable and supported by many IDEs, which can even execute the requests directly from the approved file.

Files are saved with the .http extension.

Simple GET request
GET /api/prices?id=1234567890123
Connection: Upgrade, HTTP2-Settings
Host: {{Host}}
Http2-settings: AAEAAEAAAAIAAAAAAAMAAAAAAAQBAAAAAAUAAEAAAAYABgAA
Upgrade: h2c
User-agent: {{User-agent}}
POST request with Authorization header
POST /api/price-requests/
Authorization: Bearer secret token
Connection: Upgrade, HTTP2-Settings
Content-length: 24
Host: {{Host}}
Http2-settings: AAEAAEAAAAIAAAAAAAMAAAAAAAQBAAAAAAUAAEAAAAYABgAA
Upgrade: h2c
User-agent: {{User-agent}}

{"gtin":"1234567890123"}

HTTP Scrubbers

Some HTTP headers contain values that change between test runs — the host varies by port, and the user-agent varies by JVM version. The HttpScrubbers utility provides scrubbers for these common cases:

  • hostHeaderValue() – Scrubs the Host header (varies by port)

  • userAgentHeaderValue() – Scrubs the User-agent header (varies by JVM version)

  • headerValue(name) – Scrubs any header by name

Custom Extensions — Your Own Scrubbers, Print Formats, and Reviewers

While ApproveJ ships with a range of built-in scrubbers, print formats, and file reviewers, you can create your own. A custom scrubber lets you strip domain-specific dynamic data (e.g. email addresses, internal IDs) that the built-in scrubbers do not cover. Custom print formats and file reviewers go a step further: by implementing the provider SPI, they integrate seamlessly with ApproveJ’s configuration system and can be set as global defaults via approvej.properties.

Custom Scrubber

The scrubbing chapter shows how to pass a lambda to scrubbedOf() for one-off cases. When you want to reuse a scrubber across tests, implement the Scrubber interface as a class.

The Scrubber interface has three type parameters:

interface Scrubber<I extends Scrubber<I, T, R>, T, R>
    extends UnaryOperator<T> {
  // …
}
  • T — the type of value being scrubbed (usually String)

  • R — the type of the replacement value (usually String)

  • I — the scrubber’s own type (a self-referential generic so that replacement() returns the correct type)

For the most common case — scrubbing strings — you can implement StringScrubber, which binds all three parameters to Scrubber<StringScrubber, String, String>. This means you only need to implement two methods:

  • String apply(String value) — perform the actual scrubbing

  • StringScrubber replacement(Replacement<String> replacement) — return a copy with a different replacement strategy

Here is a scrubber that replaces email addresses:

Java
package examples.java;

import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import java.util.regex.MatchResult;
import java.util.regex.Pattern;
import org.approvej.scrub.Replacement;
import org.approvej.scrub.Replacements;
import org.approvej.scrub.StringScrubber;

public class EmailScrubber implements StringScrubber { (1)

  private static final Pattern EMAIL_PATTERN =
      Pattern.compile("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}");

  private final Replacement<String> replacement;

  public EmailScrubber() {
    this(Replacements.numbered("email")); (2)
  }

  private EmailScrubber(Replacement<String> replacement) {
    this.replacement = replacement;
  }

  @Override
  public String apply(String value) { (3)
    Map<String, Integer> findings = new HashMap<>();
    Function<MatchResult, String> replacer =
        result -> {
          String group = result.group();
          findings.putIfAbsent(group, findings.size() + 1);
          return String.valueOf(replacement.apply(group, findings.get(group)));
        };
    return EMAIL_PATTERN.matcher(value).replaceAll(replacer);
  }

  @Override
  public StringScrubber replacement(Replacement<String> replacement) { (4)
    return new EmailScrubber(replacement);
  }
}
Kotlin
package examples.kotlin

import org.approvej.scrub.Replacement
import org.approvej.scrub.Replacements
import org.approvej.scrub.StringScrubber

class EmailScrubber private constructor(private val replacement: Replacement<String>) :
  StringScrubber { (1)

  constructor() : this(Replacements.numbered("email")) (2)

  override fun apply(value: String): String { (3)
    val findings = mutableMapOf<String, Int>()
    return Regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}").replace(value) { match ->
      val group = match.value
      findings.putIfAbsent(group, findings.size + 1)
      replacement.apply(group, findings[group]!!).toString()
    }
  }

  override fun replacement(replacement: Replacement<String>): StringScrubber { (4)
    return EmailScrubber(replacement)
  }
}
1 Implement StringScrubber to get the right type bindings
2 Provide a sensible default replacement
3 apply performs the actual scrubbing — find matches and replace them
4 replacement returns a new instance with the given replacement strategy, keeping the scrubber immutable

Use it like any built-in scrubber:

Java
approve("Contact jane@example.com or john@company.org for details.")
    .scrubbedOf(new EmailScrubber()) (1)
    .byFile();
Kotlin
approve("Contact jane@example.com or john@company.org for details.")
  .scrubbedOf(EmailScrubber()) (1)
  .byFile()
Approved file
Contact [email 1] or [email 2] for details.

Because the scrubber implements replacement(), callers can swap the replacement strategy:

Java
approve("Contact jane@example.com or john@company.org for details.")
    .scrubbedOf(new EmailScrubber().replacement(labeled("redacted"))) (1)
    .byFile();
Kotlin
approve("Contact jane@example.com or john@company.org for details.")
  .scrubbedOf(EmailScrubber().replacement(labeled("redacted"))) (1)
  .byFile()
1 replacement() returns a new scrubber — the original is unchanged
Approved file
Contact [redacted] or [redacted] for details.

Custom Print Format Provider

The printing chapter shows how to pass a PrintFormat instance to printedAs(). If you want your custom print format to be usable as a global default via approvej.properties, implement both PrintFormat<T> and PrintFormatProvider<T>. The provider SPI makes your format available by alias, just like the built-in ones.

  1. Implement PrintFormat<T> and PrintFormatProvider<T>

  2. Register via META-INF/services/org.approvej.configuration.Provider

Java
package examples.java;

import org.approvej.print.PrintFormat;
import org.approvej.print.PrintFormatProvider;
import org.approvej.print.Printer;
import org.jspecify.annotations.NonNull;

public class ScreamingPrintFormat implements PrintFormat<Object>, PrintFormatProvider<Object> {

  @Override
  public @NonNull String alias() {
    return "screaming";
  }

  @Override
  public PrintFormat<Object> create() {
    return new ScreamingPrintFormat();
  }

  @Override
  public Printer<Object> printer() {
    return (Object object) -> object.toString().toUpperCase();
  }

  @Override
  public String filenameExtension() {
    return "TXT";
  }
}
Kotlin
package examples.kotlin

import org.approvej.print.PrintFormat
import org.approvej.print.PrintFormatProvider
import org.approvej.print.Printer

class ScreamingPrintFormat : PrintFormat<Any>, PrintFormatProvider<Any> {
  override fun printer(): Printer<Any> = { any -> any.toString().uppercase() }

  override fun filenameExtension() = "TXT"

  override fun alias() = "screaming"

  override fun create() = ScreamingPrintFormat()
}
src/test/resources/META-INF/services/org.approvej.configuration.Provider
examples.java.ScreamingPrintFormat

Now you can set it as the default in approvej.properties:

defaultPrintFormat = screaming

Custom File Reviewer Provider

Similarly, you can create a custom file reviewer and register it via SPI to make it available as a global default.

  1. Implement FileReviewer and FileReviewerProvider

  2. Register via META-INF/services/org.approvej.configuration.Provider

Java
package examples.java;

import org.approvej.approve.PathProvider;
import org.approvej.review.FileReviewResult;
import org.approvej.review.FileReviewer;
import org.approvej.review.FileReviewerProvider;
import org.approvej.review.ReviewResult;
import org.jspecify.annotations.NonNull;

public class LoggingFileReviewer implements FileReviewer, FileReviewerProvider {

  @Override
  public ReviewResult apply(PathProvider pathProvider) {
    System.out.println("Received: " + pathProvider.receivedPath());
    System.out.println("Approved: " + pathProvider.approvedPath());
    return new FileReviewResult(false);
  }

  @Override
  public @NonNull String alias() {
    return "logging";
  }

  @Override
  public FileReviewer create() {
    return new LoggingFileReviewer();
  }
}
Kotlin
package examples.kotlin

import org.approvej.approve.PathProvider
import org.approvej.review.FileReviewResult
import org.approvej.review.FileReviewer
import org.approvej.review.FileReviewerProvider
import org.approvej.review.ReviewResult

class LoggingFileReviewer : FileReviewer, FileReviewerProvider {
  override fun apply(pathProvider: PathProvider): ReviewResult {
    println("Received: " + pathProvider.receivedPath())
    println("Approved: " + pathProvider.approvedPath())
    return FileReviewResult(false)
  }

  override fun alias() = "logging"

  override fun create() = LoggingFileReviewer()
}
META-INF/services/org.approvej.configuration.Provider
examples.java.LoggingFileReviewer

Now you can set it as the default in approvej.properties:

defaultFileReviewer = logging

Build Plugins — Manage Approved and Received Files

When you rename or delete a test method, its approved file stays behind on disk. Over time these leftover files accumulate and clutter the repository.

ApproveJ provides build plugins for Gradle and Maven that help you clean up leftovers, batch-review unapproved files, and approve received files across the project. These plugins use an inventory that ApproveJ maintains automatically during test runs.

Setup

Gradle Plugin

Gradle
plugins {
  id 'org.approvej' version '1.4.1'
}
Gradle.kts
plugins {
  id("org.approvej") version "1.4.1"
}
The plugin requires the Java plugin to be applied in the same project.

Maven Plugin

<plugin>
  <groupId>org.approvej</groupId>
  <artifactId>approvej-maven-plugin</artifactId>
  <version>1.4.1</version>
</plugin>

Clean Up Leftover Files

An approved file is considered a leftover when its originating test method no longer exists in the compiled test classes.

# Gradle
./gradlew approvejFindLeftovers  # list leftovers without deleting
./gradlew approvejCleanup        # find and remove leftovers

# Maven
mvn approvej:find-leftovers
mvn approvej:cleanup

A typical cleanup workflow:

  1. Run your tests locally so the inventory is populated.

  2. Run the find-leftovers task/goal to see which approved files are leftovers.

  3. Run the cleanup task/goal to delete the leftover files.

  4. Commit the updated inventory and the removal of leftover files.

Only approved files that have been recorded in the inventory can be detected as leftovers. Make sure you have run all relevant tests at least once before cleaning up.

Review Unapproved Files

After running your tests, some may have failed because the received value differs from the approved value. Instead of reviewing each failure individually, you can open all unapproved files in the configured default file reviewer at once.

# Gradle
./gradlew approvejReviewUnapproved

# Maven
mvn approvej:review-unapproved

This scans the inventory for approved files that have a corresponding received file and opens each pair in the file reviewer.

Approve All Received Files

If you are confident that all received files are correct, you can approve them all at once. This moves every received file to its corresponding approved file, replacing the previous content.

# Gradle
./gradlew approvejApproveAll

# Maven
mvn approvej:approve-all
This overwrites all approved files with their received counterparts without review. Make sure you check the changes before committing them to version control.

The Approved File Inventory

The build plugin tasks rely on an inventory file that ApproveJ maintains automatically. Every time a test calls byFile(), ApproveJ records the approved file path and the originating test method. At the end of the test run, all entries are merged into a project-level inventory file:

.approvej/inventory.properties

The file uses Java Properties format. Each entry maps a relative file path to the test reference that created it:

src/test/resources/MyTest-myTest-approved.txt = com.example.MyTest#myTest
Commit .approvej/inventory.properties to version control so that the inventory is shared across the team.

The inventory is updated incrementally. Only entries for test methods that ran in the current execution are refreshed. Entries for tests that did not run are preserved.

Disable the Inventory

The inventory is controlled by the inventoryEnabled property.

Environment Default

Local development (no CI environment variable)

true

CI (the CI environment variable is set)

false

You can override this default in any configuration source:

src/test/resources/approvej.properties
inventoryEnabled = true

Or via environment variable:

export APPROVEJ_INVENTORY_ENABLED=false

IntelliJ Plugin — IDE Integration

The ApproveJ IntelliJ plugin is an optional plugin that enhances the approval testing workflow directly in IntelliJ IDEA. It provides integrated diff viewing, one-click approval, bidirectional navigation between test code and approved files, and inspections for common mistakes.

Installation

Install the plugin from the JetBrains Marketplace:

  1. Open SettingsPluginsMarketplace.

  2. Search for "ApproveJ".

  3. Click Install and restart the IDE.

Diff Viewer and One-Click Approval

When a test produces a .received file, the plugin shows a banner at the top of the file with actions:

  • Compare with Approved — opens a side-by-side diff between the received and approved file.

  • Approve — copies the received file over the approved file in one click.

  • Navigate to Test — navigates to the test method that produced the file.

Similarly, when an approved file has a pending received file, its banner offers a Compare with Received action.

Banner on a received file with "Compare with Approved" and "Approve" actions
Figure 1. Banner on a received file offering to compare or approve
Side-by-side diff between received and approved file
Figure 2. Side-by-side diff between received and approved file

These actions are also available from the context menu on .received files in the project tree.

Navigation

The plugin provides bidirectional navigation between test code and approved files:

  • Test → Approved file: A gutter icon appears next to approve()…​byFile() call chains. Click it to navigate directly to the corresponding approved file.

  • Approved/Received file → Test: Banners on approved and received files offer a "Navigate to Test" action that navigates back to the approve() call.

Gutter icon on an approve call chain linking to the approved file
Figure 3. Gutter icon on an approve() call chain linking to the approved file
Banner on an approved file with "Navigate to Test" action
Figure 4. Banner on an approved file offering to navigate to the test

Dangling Approval Inspection

The plugin warns you when an approve() call is missing its terminal method (like byFile() or byValue()). Without a terminal method, the approval is never actually executed — the test silently passes without checking anything.

The inspection offers quick fixes to add a terminal method:

  • .byFile() — file-based approval (most common)

  • .byValue("") — inline approval with an empty expected value

Warning on an approve call missing a terminal method
Figure 5. Inspection warning on a dangling approve() call
Quick fixes to conclude a dangling approval
Figure 6. Quick fixes to conclude a dangling approval

Configuration — Global Defaults and Environment Settings

ApproveJ can be configured through multiple mechanisms to customize its default behavior. This allows you to set global defaults, override them per project, and further override them via environment variables.

Configuration Sources

Configuration values are resolved in the following order (highest to lowest priority):

  1. Environment variables - e.g., APPROVEJ_DEFAULT_PRINT_FORMAT

  2. Project properties file - src/test/resources/approvej.properties

  3. User home properties file - ~/.config/approvej/approvej.properties

  4. Default values

This hierarchy allows you to:

  • Set global defaults in your home directory

  • Override them per project in the project’s properties file

  • Further override them via environment variables (useful for CI/CD pipelines)

See the cheat sheet for a complete list of all supported properties.

Cheat Sheet

Quick reference for the ApproveJ public API, organized by the approval flow.

A printable PDF version of this cheat sheet is available.

Entry Point

Method Description

ApprovalBuilder.approve(T value)

Start building an approval for a value

.named(String name)

Set a custom name for the approval (used in filenames)

.printedAs(PrintFormat<T> printFormat)

Convert value to string using a PrintFormat

.printedBy(Function<T, String> printer)

Convert value to string using a custom function

.printed()

Convert value to string using the default print format

.scrubbedOf(UnaryOperator<T> scrubber)

Apply a scrubber to remove dynamic data

.reviewedBy(FileReviewer fileReviewer)

Set the file reviewer for this approval

.reviewedBy(String script)

Set a review script for this approval

.byValue(String previouslyApproved)

Approve against an inline string

.byFile()

Approve against a file next to the test

.byFile(PathProvider pathProvider)

Approve against a file at a custom path

.byFile(Path approvedPath)

Approve against a file at the given path

.byFile(String approvedPath)

Approve against a file at the given path string

.by(Function<String, ApprovalResult> approver)

Approve using a custom approver function

Print Formats

Factory Method Description

SingleLineStringPrintFormat.singleLineString()

Print via toString() on a single line (default)

MultiLineStringPrintFormat.multiLineString()

Print each property on a new line

multiLineString().sorted()

Same as above but with sorted properties

JsonPrintFormat.json()

Pretty print as JSON (requires json-jackson or json-jackson3)

JsonPrintFormat.json(ObjectMapper)

Pretty print as JSON with a custom ObjectMapper

YamlPrintFormat.yaml()

Print as YAML (requires yaml-jackson or yaml-jackson3)

YamlPrintFormat.yaml(ObjectMapper)

Print as YAML with a custom ObjectMapper

ReceivedHttpRequestPrintFormat.httpRequest()

Print as HTTP request format (requires http)

Scrubbers

String Scrubbers

Factory Method Description

Scrubbers.strings(String first, String…​ more)

Scrub specific string values

Scrubbers.stringsMatching(Pattern pattern)

Scrub strings matching a regex pattern

Scrubbers.stringsMatching(String pattern)

Scrub strings matching a regex string

Scrubbers.uuids()

Scrub UUID strings

Date/Time Scrubbers

Factory Method Description

Scrubbers.dateTimeFormat(String pattern)

Scrub dates matching a DateTimeFormatter pattern

Scrubbers.dateTimeFormat(String pattern, Locale locale)

Same with a specific locale

Scrubbers.isoLocalDates()

Scrub ISO local dates (2024-01-15)

Scrubbers.isoOffsetDates()

Scrub ISO offset dates

Scrubbers.isoDates()

Scrub ISO dates

Scrubbers.isoLocalTimes()

Scrub ISO local times (10:15:30)

Scrubbers.isoOffsetTimes()

Scrub ISO offset times

Scrubbers.isoTimes()

Scrub ISO times

Scrubbers.isoLocalDateTimes()

Scrub ISO local date-times

Scrubbers.isoOffsetDateTimes()

Scrub ISO offset date-times

Scrubbers.isoZonedDateTimes()

Scrub ISO zoned date-times

Scrubbers.isoZonedDateTimes(Locale locale)

Same with a specific locale

Scrubbers.isoDateTimes()

Scrub ISO date-times

Scrubbers.isoDateTimes(Locale locale)

Same with a specific locale

Scrubbers.isoOrdinalDates()

Scrub ISO ordinal dates

Scrubbers.isoWeekDates()

Scrub ISO week dates

Scrubbers.isoInstants()

Scrub ISO instants

Scrubbers.basicIsoDates()

Scrub basic ISO dates (20240115)

Scrubbers.rfc1123DateTimes()

Scrub RFC 1123 date-times

Field Scrubbers

Factory Method Description

Scrubbers.field(Class<T> type, String fieldName)

Scrub a field value on objects of the given type

JSON Scrubbers

Factory Method Description

JsonPointerScrubber.jsonPointer(String jsonPointer)

Scrub a JSON node at the given JSON Pointer path

HTTP Scrubbers

Factory Method Description

HttpScrubbers.headerValue(String headerName)

Scrub an HTTP header value by name

HttpScrubbers.hostHeaderValue()

Scrub the Host header value

HttpScrubbers.userAgentHeaderValue()

Scrub the User-agent header value

Replacements

Factory Method Description

Replacements.numbered(String label)

Replace with [label 1], [label 2], etc.

Replacements.numbered()

Replace with numbered placeholder using default label

Replacements.labeled(String label)

Replace with [label]

Replacements.string(String replacement)

Replace with a fixed string

Replacements.relativeDate()

Replace with relative date (e.g. [today], [yesterday])

Replacements.relativeDateTime()

Replace with relative date-time

Replacements.masking()

Mask characters (AA, aa, 01)

Approvers & Path Providers

Factory Method Description

PathProviders.nextToTest()

Place files next to the test class (default)

PathProviders.nextToTestInSubdirectory()

Place files in a subdirectory named after the test class

PathProviders.approvedPath(Path path)

Use a specific approved file path

PathProviders.approvedPath(String path)

Use a specific approved file path string

File Reviewers

Factory Method Description

Reviewers.none()

Do nothing on mismatch (default)

Reviewers.automatic()

Automatically accept all received values

Reviewers.script(String script)

Run a script with {receivedFile} and {approvedFile} placeholders

Build Plugin Tasks

Command Description

./gradlew approvejFindLeftovers

List leftover approved files (Gradle)

./gradlew approvejCleanup

Remove leftover approved files (Gradle)

./gradlew approvejApproveAll

Approve all unapproved files (Gradle)

./gradlew approvejReviewUnapproved

Review all unapproved files (Gradle)

mvn approvej:find-leftovers

List leftover approved files (Maven)

mvn approvej:cleanup

Remove leftover approved files (Maven)

mvn approvej:approve-all

Approve all unapproved files (Maven)

mvn approvej:review-unapproved

Review all unapproved files (Maven)

Configuration Properties

Property Environment Variable Default

defaultPrintFormat

APPROVEJ_DEFAULT_PRINT_FORMAT

singleLineString

defaultFileReviewer

APPROVEJ_DEFAULT_FILE_REVIEWER

none

defaultFileReviewerScript

APPROVEJ_DEFAULT_FILE_REVIEWER_SCRIPT

(none)

inventoryEnabled

APPROVEJ_INVENTORY_ENABLED

true locally, false in CI

Configuration is resolved in priority order: environment variables > project properties (src/test/resources/approvej.properties) > user home properties (~/.config/approvej/approvej.properties) > defaults.

API Reference

Javadoc for each module is available here: