diff --git a/README.md b/README.md index de74183c..a553aa97 100644 --- a/README.md +++ b/README.md @@ -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 response = new HashMap<>(); +response.put("name", "John Doe"); +response.put("createdAt", "2026-10-07"); + +expect.serializer("json") + .mask("\\d{4}-\\d{2}-\\d{2}", "") + .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 diff --git a/java-snapshot-testing-core/src/main/java/au/com/origin/snapshots/Expect.java b/java-snapshot-testing-core/src/main/java/au/com/origin/snapshots/Expect.java index 7269121b..62c056b0 100644 --- a/java-snapshot-testing-core/src/main/java/au/com/origin/snapshots/Expect.java +++ b/java-snapshot-testing-core/src/main/java/au/com/origin/snapshots/Expect.java @@ -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; @@ -22,6 +26,7 @@ public class Expect { private String scenario; private final Map headers = new HashMap<>(); + private final List> masks = new ArrayList<>(); public static Expect of(SnapshotVerifier snapshotVerifier, Method method) { return new Expect(snapshotVerifier, method); @@ -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. + * + *

Masks are applied in registration order, after serialization and before saving or comparing + * the snapshot. Snapshot names, scenarios and headers are not masked. + * + *

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. * diff --git a/java-snapshot-testing-core/src/main/java/au/com/origin/snapshots/SnapshotContext.java b/java-snapshot-testing-core/src/main/java/au/com/origin/snapshots/SnapshotContext.java index ae0398ea..d62b2788 100644 --- a/java-snapshot-testing-core/src/main/java/au/com/origin/snapshots/SnapshotContext.java +++ b/java-snapshot-testing-core/src/main/java/au/com/origin/snapshots/SnapshotContext.java @@ -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; @@ -33,6 +34,7 @@ public class SnapshotContext { @Setter private SnapshotSerializer snapshotSerializer; @Setter private SnapshotComparator snapshotComparator; @Setter private List snapshotReporters; + @Setter private List> masks = Collections.emptyList(); @Setter @Getter String scenario; @@ -145,7 +147,20 @@ private Snapshot getRawSnapshot(Collection 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 mask : masks) { + body = mask.apply(body); + } + return Snapshot.builder() + .name(snapshot.getName()) + .scenario(snapshot.getScenario()) + .header(snapshot.getHeader()) + .body(body) + .build(); } String resolveSnapshotIdentifier() { diff --git a/java-snapshot-testing-core/src/test/java/au/com/origin/snapshots/RegexMaskingTest.java b/java-snapshot-testing-core/src/test/java/au/com/origin/snapshots/RegexMaskingTest.java new file mode 100644 index 00000000..25e1251a --- /dev/null +++ b/java-snapshot-testing-core/src/test/java/au/com/origin/snapshots/RegexMaskingTest.java @@ -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)); + } +} diff --git a/java-snapshot-testing-junit5/src/test/java/au/com/origin/snapshots/docs/RegexMaskingExample.java b/java-snapshot-testing-junit5/src/test/java/au/com/origin/snapshots/docs/RegexMaskingExample.java new file mode 100644 index 00000000..840837d6 --- /dev/null +++ b/java-snapshot-testing-junit5/src/test/java/au/com/origin/snapshots/docs/RegexMaskingExample.java @@ -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 response = new HashMap<>(); + response.put("name", "John Doe"); + response.put("createdAt", "2026-10-07"); + + expect.serializer("json").mask("\\d{4}-\\d{2}-\\d{2}", "").toMatchSnapshot(response); + + response.put("createdAt", "2026-10-08"); + expect.toMatchSnapshot(response); + } +} diff --git a/java-snapshot-testing-junit5/src/test/java/au/com/origin/snapshots/docs/__snapshots__/RegexMaskingExample.snap b/java-snapshot-testing-junit5/src/test/java/au/com/origin/snapshots/docs/__snapshots__/RegexMaskingExample.snap new file mode 100644 index 00000000..4be6ea9c --- /dev/null +++ b/java-snapshot-testing-junit5/src/test/java/au/com/origin/snapshots/docs/__snapshots__/RegexMaskingExample.snap @@ -0,0 +1,16 @@ +au.com.origin.snapshots.docs.RegexMaskingExample.maskDates=[ +Created on **-**-**** +] + + +au.com.origin.snapshots.docs.RegexMaskingExample.maskJson=[ + { + "createdAt": "", + "name": "John Doe" + } +] + + +au.com.origin.snapshots.docs.RegexMaskingExample.maskMultipleValues=[ +Created on **-**-****, ID: MASKED +] \ No newline at end of file