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 actually 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 human reviewer to approve received output or to fix the code.

Approval testing is especially useful for testing complex objects or large data sets, where it is impractical to write individual assertions for each property.

ApproveJ

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

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

Getting Started

Requirements

In oder 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.2'
Gradle.kts
testImplementation("org.approvej:core:1.2")
Maven
<dependency>
  <groupId>org.approvej</groupId>
  <artifactId>core</artifactId>
  <version>1.2</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.2')
implementation 'org.approvej:json-jackson'
Gradle.kts
implementation(platform("org.approvej:bom:1.2"))
implementation("org.approvej:json-jackson")
Maven
<project>
  <!--…-->
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.approvej</groupId>
        <artifactId>bom</artifactId>
        <version>1.2</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>
  <!-- … -->
  <dependencies>
    <dependency>
      <groupId>org.approvej</groupId>
      <artifactId>json-jackson</artifactId>
    </dependency>
  </dependencies>
  <!-- … -->
</project>

Basics

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> 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 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.

In order to change the way objects are being transformed to strings, you can use the ApprovalBuilder’s `printedBy method with a Printer/Function<T, String>, or its printedAs method with a PrintFormat.

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

Generic Multi Line String Print Format

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. You can use this format by calling the printedAs method on the builder.

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
]

Custom Printer Function

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

Custom Printer Format

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

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 static 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

See Configuration on how to configure a global default print format.

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.

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

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 uuids and instants.

Java
BlogPost 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]
]

Generally a built-in Scrubber uses a replacement function that replaces all matches with a numbered placeholder in the form of [<label> <counter>] (e.g. [uuid 1], [date 2], …). Note that two matches of the same (e.g. the same UUID in two places) will be replaced with the same placeholder, so you can still see that two scrubbed values were equal.

Custom 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.

In case you want to reuse the Scrubber, you can also define in a separate class implementing the Scrubber<T> interface.

Built-In Scrubbers & Replacements

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

Note that RegexScrubber implementations also allow to change the Replacement function. Built-in implementation can be found in the Replacements utility class.

Approving — adjust the verification

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

Approve by Value

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 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.

NOTE

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 Custom Printer Format 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 a 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 in the to determine the received file path. Otherwise, the word received will be added at the end of the filename.

For example

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

  • src/test/resources/BasicsDocTest-approve_file_approved_path.yamlsrc/test/resources/BasicsDocTest-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/BasicsDocTest-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.

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. This can simply be done by finding the failing test and compare the received and approved files within your IDE or with an external tool. To help you with that process, ApproveJ allows to configure a script or a Reviewer instance that will open such a tool automatically.

Blocking/Non-Blocking Review

Some FileReviewers 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.

Choose Reviewer in Test

You can choose the FileReviewer to use in the test code 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.

WARN: 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()

Configure the Default Reviewer Globally

See Configuration on how to configure a global default reviewer.

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.2'
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.2")
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.2</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.2'
implementation 'tools.jackson.core:jackson-databind:3.0.0'
Gradle.kts
implementation("org.approvej:json-jackson3:1.2")
implementation("tools.jackson.core:jackson-databind:3.0.0")
Maven
<dependency>
  <groupId>org.approvej</groupId>
  <artifactId>json-jackson3</artifactId>
  <version>1.2</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.

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.*

Scrub JSON

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.

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]")) (1)
    .scrubbedOf(jsonPointer("/published").replacement("[scrubbed published]")) (2)
    .by(file(nextToTest().filenameExtension("json"))); (3)
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]")) (1)
  .scrubbedOf(jsonPointer("/published").replacement("[scrubbed published]")) (2)
  .by(file(nextToTest().filenameExtension("json"))) (3)
1 scrubs the dynamically assigned id node on the root level of the JSON and replaces it with "[scrubbed id]"
2 scrubs the published timestamp node on the root level of the JSON and replaces it with "[scrubbed published]"
3 stores the received data at a file next to the test with the file extension .json
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]"}

Pretty Print JSON

In the example above, the JSON persisted in a one-liner. Even for the simple example, this is not very readable, let alone easily comparable to a slightly different JSON. To improve this, you can use the JsonPrintFormat.

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 to convert the JsonNode object to a string

Now the approved file is much more readable.

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]"
}

Pretty Print JSON Strings

It is not necessary to parse the JSON string before printing it. If the value is a string, JsonPrintFormat automatically pretty prints it. The only downside is, that you can not use JSON-specific scrubbers.

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]"
}

Note that the applied scrubbers also replaced the tag IDs.

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.2'
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.2")
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.2</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.2'
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.2")
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.2</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.

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 to print any object in YAML format.

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

ApproveJ provides capabilities to approval test HTTP requests sent by your software component. The core concept is the ReceivedHttpRequest record, which captures the method, URI, headers, and body of an HTTP request.

There are two ways to capture HTTP requests for approval:

  • HttpStubServer – A lightweight stub server included in the http module. Use this when you need a simple way to capture requests without additional dependencies.

  • WireMock Adapter – An adapter in the http-wiremock module that converts WireMock requests. Use this when you already have WireMock in your test setup.

Common Concepts

ReceivedHttpRequest

The ReceivedHttpRequest is a record that holds the captured request data:

  • method() – The HTTP method (GET, POST, etc.)

  • uri() – The request URI including path and query parameters

  • headers() – A sorted map of header names to values

  • body() – The request body as a string

ReceivedHttpRequestPrintFormat

The ReceivedHttpRequestPrintFormat prints requests in the HTTP request file format, which is readable and supported by many IDEs.

Files are saved with the .http extension.

HTTP Scrubbers

The HttpScrubbers utility provides scrubbers for common dynamic HTTP headers:

  • 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

HttpStubServer

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

Dependency

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

Usage

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))

The server stores all received requests. You can retrieve and approve them:

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()

The approved files look like this:

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"}

WireMock Adapter

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

Dependency

The adapter requires both the http module and WireMock.

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

Usage

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

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.

Configuration

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)

Supported Properties

The following properties can be configured:

Property Description Default

defaultPrintFormat

The default print format to use if none is specified via the printedAs method. Can be an alias (e.g., json, yaml) or a fully-qualified class name.

singleLineString

defaultFileReviewer

The default file reviewer to use. Can be an alias (e.g., none, automatic), a fully-qualified class name, or a script with placeholders.

none

defaultFileReviewerScript

The script to execute for reviewing differences. This implicitly sets the defaultFileReviewer to script

(none)

Built-in Aliases

ApproveJ provides built-in aliases for commonly used print formats and reviewers:

Table 1. Print Format Aliases
Alias Class

singleLineString

org.approvej.print.SingleLineStringPrintFormat

multiLineString

org.approvej.print.MultiLineStringPrintFormat

json

org.approvej.json.jackson.JsonPrintFormat (requires approvej-json-jackson module)

yaml

org.approvej.yaml.jackson.YamlPrintFormat (requires approvej-yaml-jackson module)

Table 2. File Reviewer Aliases
Alias Description

none

Does nothing, test simply fails if values differ

automatic

Automatically accepts all received values (overwrites approved files)

Example Configuration

src/test/resources/approvej.properties
# Use JSON as the default print format (requires approvej-json-jackson module)
defaultPrintFormat = json

# Configure a diff tool for reviewing
defaultFileReviewer = idea diff --wait "{receivedFile}" "{approvedFile}"

Alternatively, using fully-qualified class names:

defaultPrintFormat = org.approvej.json.jackson.JsonPrintFormat
When using fully-qualified class names, the referenced class needs to provide a public default constructor.

Environment Variables

Environment variables use the APPROVEJ_ prefix and convert camelCase property names to SCREAMING_SNAKE_CASE:

Property Name Environment Variable

defaultPrintFormat

APPROVEJ_DEFAULT_PRINT_FORMAT

defaultFileReviewer

APPROVEJ_DEFAULT_FILE_REVIEWER

Example: Set default print format via environment variable
export APPROVEJ_DEFAULT_PRINT_FORMAT=json

Configuring a Review Script

When the received value differs from the approved value, ApproveJ can automatically open a diff tool to help you review the changes.

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

Example diff tool configuration for IntelliJ IDEA
# IntelliJ IDEA (blocking)
defaultFileReviewerScript = idea diff --wait "{receivedFile}" "{approvedFile}"
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.

Custom Extensions

ApproveJ uses a Service Provider Interface (SPI) mechanism that allows you to register custom print formats and reviewers. These can then be referenced by alias in the configuration.

Custom Print Format Provider

To create a custom print format that can be configured via alias:

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

  2. Register it 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 use it in configuration:

defaultPrintFormat = screaming

Custom File Reviewer Provider

To create a custom file reviewer that can be configured via alias:

  1. Implement FileReviewer and FileReviewerProvider

  2. Register it 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 use it in configuration:

defaultFileReviewer = logging