Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,47 @@ Hello World
]
```

## Masking non-deterministic values

Use `.mask(regex, replacement)` to replace changing values such as dates or IDs in the serialized
snapshot body before it is saved or compared. For example, both calls below match the same snapshot:

```java
expect.mask("\\d{2}-\\d{2}-\\d{4}", "**-**-****")
.toMatchSnapshot("Created on 07-10-2026");
expect.toMatchSnapshot("Created on 08-10-2026");
```

You can chain multiple masks. They are applied in the order they are registered, so later masks see
the result of earlier replacements:

```java
expect.mask("\\d{2}-\\d{2}-\\d{4}", "**-**-****")
.mask("ID: \\d+", "ID: MASKED")
.toMatchSnapshot("Created on 07-10-2026, ID: 123");
```

Masks also work with custom serializers, including JSON:

```java
Map<String, Object> response = new HashMap<>();
response.put("name", "John Doe");
response.put("createdAt", "2026-10-07");

expect.serializer("json")
.mask("\\d{4}-\\d{2}-\\d{2}", "<DATE>")
.toMatchSnapshot(response);
```

Snapshot names, scenarios and headers are not masked. Masks belong to the `Expect` instance and
remain active for its subsequent calls. Patterns use Java regular expressions, including inline
flags such as `(?m)` for multiline matching. Replacements follow `Matcher.replaceAll` semantics,
including capture-group references such as `$1`; use `Matcher.quoteReplacement` for literal
replacements containing `$` or `\`. Invalid patterns are rejected when `.mask(...)` is called.

When adding masks to an existing test, update and review its snapshots so they contain the masked
values. Runnable examples are in `RegexMaskingExample.java` under the JUnit 5 module's `docs` package.

## Advantages of Snapshot Testing

- Great for testing JSON interfaces ensuring you don't break clients
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@
import au.com.origin.snapshots.reporters.SnapshotReporter;
import au.com.origin.snapshots.serializers.SnapshotSerializer;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.UnaryOperator;
import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;

Expand All @@ -22,6 +26,7 @@ public class Expect {
private String scenario;

private final Map<String, String> headers = new HashMap<>();
private final List<UnaryOperator<String>> masks = new ArrayList<>();

public static Expect of(SnapshotVerifier snapshotVerifier, Method method) {
return new Expect(snapshotVerifier, method);
Expand Down Expand Up @@ -51,12 +56,37 @@ public void toMatchSnapshot(Object object) {
snapshotContext.setScenario(scenario);
}
snapshotContext.header.putAll(headers);
snapshotContext.setMasks(new ArrayList<>(masks));

snapshotContext.checkValidContext();

snapshotContext.toMatchSnapshot();
}

/**
* Mask all matches of a regular expression in the serialized snapshot body.
*
* <p>Masks are applied in registration order, after serialization and before saving or comparing
* the snapshot. Snapshot names, scenarios and headers are not masked.
*
* <p>The replacement follows {@link java.util.regex.Matcher#replaceAll(String)} semantics,
* including capture-group references. Use {@link
* java.util.regex.Matcher#quoteReplacement(String)} for literal replacements containing dollar
* signs or backslashes.
*
* @param regex regular expression to match
* @param replacement replacement for each match
* @return this
* @throws java.util.regex.PatternSyntaxException if the regular expression is invalid
* @throws NullPointerException if regex or replacement is null
*/
public Expect mask(String regex, String replacement) {
Pattern pattern = Pattern.compile(regex);
Objects.requireNonNull(replacement, "replacement");
masks.add(body -> pattern.matcher(body).replaceAll(replacement));
return this;
}

/**
* Normally a snapshot can be applied only once to a test method.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import au.com.origin.snapshots.serializers.SnapshotSerializer;
import java.lang.reflect.Method;
import java.util.*;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
import lombok.Getter;
import lombok.Setter;
Expand All @@ -33,6 +34,7 @@ public class SnapshotContext {
@Setter private SnapshotSerializer snapshotSerializer;
@Setter private SnapshotComparator snapshotComparator;
@Setter private List<SnapshotReporter> snapshotReporters;
@Setter private List<UnaryOperator<String>> masks = Collections.emptyList();

@Setter @Getter String scenario;

Expand Down Expand Up @@ -145,7 +147,20 @@ private Snapshot getRawSnapshot(Collection<Snapshot> rawSnapshots) {

private Snapshot takeSnapshot() {
SnapshotSerializerContext sg = SnapshotSerializerContext.from(this);
return snapshotSerializer.apply(current, sg);
Snapshot snapshot = snapshotSerializer.apply(current, sg);
if (masks.isEmpty()) {
return snapshot;
}
String body = snapshot.getBody();
for (UnaryOperator<String> mask : masks) {
body = mask.apply(body);
}
return Snapshot.builder()
.name(snapshot.getName())
.scenario(snapshot.getScenario())
.header(snapshot.getHeader())
.body(body)
.build();
}

String resolveSnapshotIdentifier() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package au.com.origin.snapshots;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;

import au.com.origin.snapshots.annotations.SnapshotName;
import au.com.origin.snapshots.config.BaseSnapshotConfig;
import au.com.origin.snapshots.exceptions.SnapshotMatchException;
import au.com.origin.snapshots.serializers.SnapshotSerializer;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.regex.Matcher;
import java.util.regex.PatternSyntaxException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

class RegexMaskingTest {

@TempDir Path tempDir;

private SnapshotVerifier snapshotVerifier;
private Method snapshotMethod;
private Path snapshotPath;

@BeforeEach
void setUp() throws NoSuchMethodException {
snapshotVerifier =
new SnapshotVerifier(
new BaseSnapshotConfig() {
@Override
public String getOutputDir() {
return tempDir.toString();
}
},
RegexMaskingTest.class);
snapshotMethod = RegexMaskingTest.class.getDeclaredMethod("snapshot");
snapshotPath = tempDir.resolve("au/com/origin/snapshots/__snapshots__/RegexMaskingTest.snap");
}

@SnapshotName("snapshot-2026-10-07")
private void snapshot() {}

private Expect expect() {
return Expect.of(snapshotVerifier, snapshotMethod);
}

private Snapshot savedSnapshot() throws Exception {
return Snapshot.parse(new String(Files.readAllBytes(snapshotPath), StandardCharsets.UTF_8));
}

@Test
void shouldMatchDifferentDatesAfterMasking() throws Exception {
expect()
.mask("\\d{2}-\\d{2}-\\d{4}", "**-**-****")
.toMatchSnapshot("Created on 07-10-2026 and updated on 08-10-2026");

assertDoesNotThrow(
() ->
expect()
.mask("\\d{2}-\\d{2}-\\d{4}", "**-**-****")
.toMatchSnapshot("Created on 09-10-2026 and updated on 10-10-2026"));
assertThat(savedSnapshot().getBody())
.isEqualTo("[\nCreated on **-**-**** and updated on **-**-****\n]");
}

@Test
void shouldStillFailForChangesOutsideMaskedValues() {
expect().mask("\\d{4}-\\d{2}-\\d{2}", "DATE").toMatchSnapshot("Created on 2026-10-07");

assertThrows(
SnapshotMatchException.class,
() ->
expect().mask("\\d{4}-\\d{2}-\\d{2}", "DATE").toMatchSnapshot("Deleted on 2026-10-08"));
}

@Test
void shouldApplyMasksInRegistrationOrder() throws Exception {
expect().mask("\\d+", "NUMBER").mask("NUMBER", "MASKED").toMatchSnapshot("ID 12, ID 34");

assertThat(savedSnapshot().getBody()).isEqualTo("[\nID MASKED, ID MASKED\n]");
}

@Test
void shouldApplyDifferentReplacementsForTheSamePattern() throws Exception {
expect().mask("x", "xx").mask("x", "y").toMatchSnapshot("x");

assertThat(savedSnapshot().getBody()).isEqualTo("[\nyy\n]");
}

@Test
void shouldMaskEmptyLinesAndTabsWithMultilinePatterns() throws Exception {
expect()
.mask("(?m)^$", "EMPTY")
.mask("(?m)^\\t+$", "TABS_ONLY")
.toMatchSnapshot("first\n\n\t\t\nlast");

assertThat(savedSnapshot().getBody()).isEqualTo("[\nfirst\nEMPTY\nTABS_ONLY\nlast\n]");
}

@Test
void shouldLeaveNonMatchingTextUnchanged() throws Exception {
expect().mask("\\d+", "NUMBER").toMatchSnapshot("Hello World");

assertThat(savedSnapshot().getBody()).isEqualTo("[\nHello World\n]");
}

@Test
void shouldSupportCaptureGroupsInReplacement() throws Exception {
expect().mask("(ID: )\\d+", "$1MASKED").toMatchSnapshot("ID: 123");

assertThat(savedSnapshot().getBody()).isEqualTo("[\nID: MASKED\n]");
}

@Test
void shouldSupportEscapedLiteralReplacement() throws Exception {
String replacement = "$amount\\path";
expect().mask("\\d+", Matcher.quoteReplacement(replacement)).toMatchSnapshot("123");

assertThat(savedSnapshot().getBody()).isEqualTo("[\n" + replacement + "\n]");
}

@Test
void shouldSupportRemovingMatchedText() throws Exception {
expect().mask("\\d+", "").toMatchSnapshot("ID 123");

assertThat(savedSnapshot().getBody()).isEqualTo("[\nID \n]");
}

@Test
void shouldPreserveSnapshotMetadataFromCustomSerializer() throws Exception {
SnapshotSerializer serializer =
new SnapshotSerializer() {
@Override
public Snapshot apply(Object object, SnapshotSerializerContext context) {
return Snapshot.builder()
.name("custom-123")
.scenario("scenario-456")
.header(context.getHeader())
.body("[\nID 789\n]")
.build();
}

@Override
public String getOutputFormat() {
return "TEXT";
}
};

expect()
.serializer(serializer)
.header("version", "123")
.mask("\\d+", "NUMBER")
.toMatchSnapshot(new Object());

Snapshot saved = savedSnapshot();
assertThat(saved.getName()).isEqualTo("custom-123");
assertThat(saved.getScenario()).isEqualTo("scenario-456");
assertThat(saved.getHeader().get("version")).isEqualTo("123");
assertThat(saved.getBody()).isEqualTo("[\nID NUMBER\n]");
}

@Test
void shouldReuseMasksAcrossScenarios() throws Exception {
Expect expect = expect().mask("\\d+", "NUMBER");
expect.scenario("first").toMatchSnapshot("ID 123");
expect.scenario("second").toMatchSnapshot("ID 456");

assertDoesNotThrow(() -> expect.scenario("first").toMatchSnapshot("ID 789"));
assertDoesNotThrow(() -> expect.scenario("second").toMatchSnapshot("ID 987"));
String saved = new String(Files.readAllBytes(snapshotPath), StandardCharsets.UTF_8);
assertThat(saved).contains("snapshot-2026-10-07[first]=[\nID NUMBER\n]");
assertThat(saved).contains("snapshot-2026-10-07[second]=[\nID NUMBER\n]");
}

@Test
void shouldKeepMasksLocalToEachExpectation() {
expect().mask("\\d+", "NUMBER").toMatchSnapshot("ID 123");

assertThrows(SnapshotMatchException.class, () -> expect().toMatchSnapshot("ID 123"));
}

@Test
void shouldRejectInvalidRegexWhenRegisteringMask() {
assertThrows(PatternSyntaxException.class, () -> expect().mask("[", "MASKED"));
}

@Test
void shouldRejectNullPattern() {
assertThrows(NullPointerException.class, () -> expect().mask(null, "MASKED"));
}

@Test
void shouldRejectNullReplacement() {
assertThrows(NullPointerException.class, () -> expect().mask("\\d+", null));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package au.com.origin.snapshots.docs;

import au.com.origin.snapshots.Expect;
import au.com.origin.snapshots.junit5.SnapshotExtension;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

@ExtendWith(SnapshotExtension.class)
public class RegexMaskingExample {

private Expect expect;

@Test
public void maskDates() {
expect.mask("\\d{2}-\\d{2}-\\d{4}", "**-**-****").toMatchSnapshot("Created on 07-10-2026");
expect.toMatchSnapshot("Created on 08-10-2026");
}

@Test
public void maskMultipleValues() {
expect
.mask("\\d{2}-\\d{2}-\\d{4}", "**-**-****")
.mask("ID: \\d+", "ID: MASKED")
.toMatchSnapshot("Created on 07-10-2026, ID: 123");
expect.toMatchSnapshot("Created on 08-10-2026, ID: 456");
}

@Test
public void maskJson() {
Map<String, Object> response = new HashMap<>();
response.put("name", "John Doe");
response.put("createdAt", "2026-10-07");

expect.serializer("json").mask("\\d{4}-\\d{2}-\\d{2}", "<DATE>").toMatchSnapshot(response);

response.put("createdAt", "2026-10-08");
expect.toMatchSnapshot(response);
}
}
Loading