diff --git a/docs/extend/core-concepts.mdx b/docs/extend/core-concepts.mdx index 4282b5e..70bb057 100644 --- a/docs/extend/core-concepts.mdx +++ b/docs/extend/core-concepts.mdx @@ -56,6 +56,7 @@ public class MatchContext { boolean strictMatching; String matchKey; String matchValue; + KeyMatchStrategy keyMatchStrategy; // defaults to EXACT, controls how expectedRecordKey is matched Map expectedAttributes; // since 1.1.1, defaults to emptyMap(), used by AttributeRecordMatcher // Convenience, for single-record matchers @@ -68,6 +69,8 @@ public class MatchContext { `expectedAttributes` holds the key/value pairs to assert against a record's `attributes` map, for example `{"statusCode": "200"}`. It follows the same convention as `excludedFields`: both default to an empty collection rather than null, and matchers check `isEmpty()` instead of null. The default `buildMatchContext()` in `AbstractKafkaConsumer` sets `strictMatching` to `false` and leaves `matchKey`, `matchValue`, and `expectedAttributes` unset. +`keyMatchStrategy` controls how `expectedRecordKey` is matched against the actual record key. It defaults to `KeyMatchStrategy.EXACT` for backward compatibility. Available strategies: `EXACT`, `CONTAINS`, `STARTS_WITH`, `ENDS_WITH`, `REGEX`. The strategy is applied at two layers: the fetch-time pre-filter in `KafkaRecordFetcher.passesKeyFilter()` (to skip non-matching records during polling) and the assertion-time key matchers (`KeyRecordMatcher`, `FileKeyRecordMatcher`, `AvroKeyRecordMatcher`, `AvroFileKeyRecordMatcher`). + --- ## `MatchResult` @@ -104,6 +107,7 @@ Key fields: | `matchFilePaths` | `List` | Expected file paths (single or batch) | | `excludedFields` | `List` | Field names to ignore in comparison | | `expectedRecordKey` | `String` | Key filter, the record must match this key | +| `keyMatchStrategy` | `KeyMatchStrategy` | How `expectedRecordKey` is matched: `EXACT` (default), `CONTAINS`, `STARTS_WITH`, `ENDS_WITH`, `REGEX` | | `readTimeout` | `long` | Milliseconds | | `consumerDeltaTime` | `long` | Milliseconds (DataTable seconds × 1000) | | `isBatchConsumer` | `boolean` | Enables batch fetch mode | @@ -185,4 +189,3 @@ All file reads go through `FileUtils.getFileContent(path)`, which transparently 3. No changes needed to `FileUtils` or `DynamicVariableProcessor`. See [Dynamic variables →](../write-tests/dynamic-variables) for the full list of built-in types. - diff --git a/docs/extend/matchers/built-in-matchers.mdx b/docs/extend/matchers/built-in-matchers.mdx index caef821..a531dad 100644 --- a/docs/extend/matchers/built-in-matchers.mdx +++ b/docs/extend/matchers/built-in-matchers.mdx @@ -44,17 +44,17 @@ Compares the record value character-for-character against a file's content after ### `FileKeyRecordMatcher` -Asserts both the record **key** and **value**. The expected file must contain the key on line 1 and the value from line 2 onwards. +Asserts both the record **key** and **value**. The expected file must contain the key on line 1 and the value from line 2 onwards. Key matching uses the `KeyMatchStrategy` from `MatchContext` (defaults to `EXACT`). -- **MatchContext fields used:** `matchFilePaths.get(0)` +- **MatchContext fields used:** `matchFilePaths.get(0)`, `keyMatchStrategy` --- ### `KeyRecordMatcher` -Asserts only the record **key**, the value is not evaluated. +Asserts only the record **key**, the value is not evaluated. Key matching uses the `KeyMatchStrategy` from `MatchContext` (defaults to `EXACT`). -- **MatchContext fields used:** `matchKey` (falls back to `matchFilePaths.get(0)` if blank) +- **MatchContext fields used:** `matchKey` (falls back to `matchFilePaths.get(0)` if blank), `keyMatchStrategy` --- @@ -116,13 +116,13 @@ Avro equivalent of `FileRecordMatcher`. Converts `GenericRecord` → JSON string ### `AvroFileKeyRecordMatcher` -Avro equivalent of `FileKeyRecordMatcher`. Asserts key + Avro-serialised JSON value. +Avro equivalent of `FileKeyRecordMatcher`. Asserts key + Avro-serialised JSON value. Key matching uses the `KeyMatchStrategy` from `MatchContext` (defaults to `EXACT`). --- ### `AvroKeyRecordMatcher` -Avro equivalent of `KeyRecordMatcher`. Key-only assertion; Avro value ignored. +Avro equivalent of `KeyRecordMatcher`. Key-only assertion; Avro value ignored. Key matching uses the `KeyMatchStrategy` from `MatchContext` (defaults to `EXACT`). --- @@ -139,4 +139,4 @@ Avro equivalent of `FieldsRecordMatcher`. Extracts a character range from the JS 3. XML/XPath matchers throw `ConsumerException` when called via `RecordMatcherFactory.forAvro()`. 4. `NoOpRecordMatcher` is the default when no match method is configured. 5. `expectedAttributes` defaults to `Collections.emptyMap()`, `AttributeRecordMatcher` checks `isEmpty()`, never `null`, same convention as `excludedFields`. - +6. `keyMatchStrategy` defaults to `KeyMatchStrategy.EXACT`, key matchers (`KeyRecordMatcher`, `FileKeyRecordMatcher`, `AvroKeyRecordMatcher`, `AvroFileKeyRecordMatcher`) and the fetch-time key filter (`KafkaRecordFetcher.passesKeyFilter()`) both delegate to `KeyMatchStrategy.matches(expected, actual)`. Available strategies: `EXACT`, `CONTAINS`, `STARTS_WITH`, `ENDS_WITH`, `REGEX`. diff --git a/docs/write-tests/step-reference.mdx b/docs/write-tests/step-reference.mdx index 222581a..ec929c2 100644 --- a/docs/write-tests/step-reference.mdx +++ b/docs/write-tests/step-reference.mdx @@ -207,8 +207,8 @@ Raw file match, full value equality. ```gherkin Then expected record from file - | topicAlias | file | expectedRecordKey | consumerReadTimeout | consumerDeltaTime | - | orders-out | expected.json | key-001 | 30 | 60 | + | topicAlias | file | expectedRecordKey | keyMatchStrategy | consumerReadTimeout | consumerDeltaTime | + | orders-out | expected.json | key-001 | exact | 30 | 60 | ``` | Column | Type | Required | Default | Description | @@ -216,6 +216,7 @@ Then expected record from file | `topicAlias` | `string` | ✅ | none | Output topic alias | | `file` | `string` | ✅ | none | Expected file path | | `expectedRecordKey` | `string` | ❌ | none | Filter by record key | +| `keyMatchStrategy` | `string` | ❌ | `exact` | Key matching strategy: `exact`, `contains`, `starts_with`, `ends_with`, `regex` | | `consumerReadTimeout` | `integer` | ❌ | Config (30) | Seconds to wait | | `consumerDeltaTime` | `integer` | ❌ | Config (60) | Seconds to seek back | @@ -247,8 +248,8 @@ Structural XML comparison with optional element exclusions. ```gherkin Then expected record from file based on XML - | topicAlias | file | excludedElements | expectedRecordKey | - | orders-out | expected.xml | ns:CreationDateTime | | + | topicAlias | file | excludedElements | expectedRecordKey | keyMatchStrategy | + | orders-out | expected.xml | ns:CreationDateTime | | | ``` | Column | Type | Required | Default | Description | @@ -257,6 +258,7 @@ Then expected record from file based on XML | `file` | `string` | ✅ | none | Expected XML file | | `excludedElements` | `string` | ❌ | none | Comma-separated element names to skip | | `expectedRecordKey` | `string` | ❌ | none | Filter by record key | +| `keyMatchStrategy` | `string` | ❌ | `exact` | Key matching strategy: `exact`, `contains`, `starts_with`, `ends_with`, `regex` | | `consumerReadTimeout` | `integer` | ❌ | Config (30) | Seconds to wait | | `consumerDeltaTime` | `integer` | ❌ | Config (60) | Seconds to seek back | @@ -286,8 +288,8 @@ Avro → JSON file match with optional field exclusions. ```gherkin Then expected record from file based on schema - | topicAlias | file | excludedKeys | expectedRecordKey | consumerReadTimeout | - | orders-out | expected.json | createdAt,eventId | ord-001 | 30 | + | topicAlias | file | excludedKeys | expectedRecordKey | keyMatchStrategy | consumerReadTimeout | + | orders-out | expected.json | createdAt,eventId | ord-001 | exact | 30 | ``` | Column | Type | Required | Default | Description | @@ -296,6 +298,7 @@ Then expected record from file based on schema | `file` | `string` | ✅ | none | Expected JSON file | | `excludedKeys` | `string` | ❌ | none | Comma-separated Avro field names to exclude | | `expectedRecordKey` | `string` | ❌ | none | Filter by record key | +| `keyMatchStrategy` | `string` | ❌ | `exact` | Key matching strategy: `exact`, `contains`, `starts_with`, `ends_with`, `regex` | | `consumerReadTimeout` | `integer` | ❌ | Config (30) | Seconds to wait | | `consumerDeltaTime` | `integer` | ❌ | Config (60) | Seconds to seek back | @@ -382,14 +385,16 @@ Positive watcher, asserts at least one record is present. ```gherkin And record should appear in topic - | topicAlias | topicType | consumerReadTimeout | consumerDeltaTime | - | orders-out | raw | 15 | 60 | + | topicAlias | topicType | expectedRecordKey | keyMatchStrategy | consumerReadTimeout | consumerDeltaTime | + | orders-out | raw | order- | starts_with | 15 | 60 | ``` | Column | Type | Required | Default | Description | |---|---|---|---|---| | `topicAlias` | `string` | ✅ | none | Output topic alias | | `topicType` | `string` | ✅ | none | `raw` or `avro` | +| `expectedRecordKey` | `string` | ❌ | none | Filter by record key | +| `keyMatchStrategy` | `string` | ❌ | `exact` | Key matching strategy: `exact`, `contains`, `starts_with`, `ends_with`, `regex` | | `consumerReadTimeout` | `integer` | ❌ | Config (30) | Seconds to wait | | `consumerDeltaTime` | `integer` | ❌ | Config (60) | Seconds to seek back | @@ -401,8 +406,8 @@ Negative watcher, asserts no record appears within the timeout. ```gherkin And record should not appear in topic - | topicAlias | topicType | consumerReadTimeout | consumerDeltaTime | - | orders-out | raw | 10 | 30 | + | topicAlias | topicType | expectedRecordKey | keyMatchStrategy | consumerReadTimeout | consumerDeltaTime | + | orders-out | raw | non-existent | exact | 10 | 30 | ``` Same columns as `record should appear in topic`. @@ -424,6 +429,46 @@ Same columns as `record should appear in topic`. --- +## Key matching strategies + +When a step includes an `expectedRecordKey` column, you can optionally specify a `keyMatchStrategy` column to control how the expected key is matched against the actual record key. This is useful when record keys are dynamically generated (UUIDs, prefixed keys, timestamps) and an exact match is too restrictive. + +| Strategy | DataTable value | Behaviour | Example | +|---|---|---|---| +| `EXACT` | `exact` | Exact string equality (default) | `"order-001"` matches `"order-001"` | +| `CONTAINS` | `contains` | Expected key is a substring of the actual key | `"order"` matches `"order-001"` | +| `STARTS_WITH` | `starts_with` | Actual key starts with the expected key | `"order-"` matches `"order-001"` | +| `ENDS_WITH` | `ends_with` | Actual key ends with the expected key | `"001"` matches `"order-001"` | +| `REGEX` | `regex` | Actual key matches the expected Java regex pattern | `"order-\\d+"` matches `"order-001"` | + +:::tip[Parsing] +The `keyMatchStrategy` value is case-insensitive and tolerant of hyphens, underscores, and spaces. For example, `starts_with`, `starts-with`, `starts_with`, `StartsWith`, and `starts with` all resolve to `STARTS_WITH`. Unrecognised values default to `EXACT`. +::: + +:::warning[Topic isolation] +When using non-exact strategies (`contains`, `regex`, `starts_with`), ensure your match pattern is specific enough to uniquely identify the intended record. Kafka topics may contain records from prior scenarios in the same feature file, and a broad pattern (e.g. `contains "key"`) may match a decoy record first, causing value mismatches. +::: + +### Example: matching a UUID-prefixed key + +```gherkin +# Record key is "550e8400-e29b-41d4-a716-446655440000" +Then expected record from file + | topicAlias | file | expectedRecordKey | keyMatchStrategy | consumerReadTimeout | + | orders-out | expected.json | 550e8400 | starts_with | 30 | +``` + +### Example: matching a dynamic key with regex + +```gherkin +# Record key is "ORD-2026-000001" +Then expected record from file + | topicAlias | file | expectedRecordKey | keyMatchStrategy | consumerReadTimeout | + | orders-out | expected.json | ORD-\d{4}-\d{6} | regex | 30 | +``` + +--- + ## See also - [Multi-row DataTables →](advanced/multi-row-datatables)