[269] implement method to assert Notifications sent in tests - #270
[269] implement method to assert Notifications sent in tests#270vitgrams wants to merge 43 commits into
Conversation
f255898 to
6677d9e
Compare
…in (more generic) refs: #269
5c67a9f to
c51430d
Compare
c51430d to
24267cc
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 24267cca3e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
@yburlakov Assigned to myself. Some improvements are required |
…in (more generic) refs: #269
…otifications-in-tests' into 269-implement-method-to-assert-notifications-in-tests
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f7259ea0d4
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| protected function prepareNotification(array $notification, array $options): array | ||
| { | ||
| foreach ($options as $key => $chain) { | ||
| $notification[$key] = $this->resolveNotificationChain($notification['notification'], $chain); |
There was a problem hiding this comment.
Pass the notifiable when resolving payload methods
When an option targets a channel payload method that needs the notifiable, such as Laravel's usual toArray(object $notifiable) or toMail(object $notifiable), this invokes the chain with only the notification object even though the fake entry still contains $notification['notifiable']. The documented ['toArray()']/push-notification helper use case will therefore throw an ArgumentCountError for normal notifications instead of exporting or comparing the payload; the resolver needs a way to pass the stored notifiable before the notification is replaced with attributes.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
I think this may make sense because $notifiable is not an arbitrary argument, but part of the contract of any Laravel notification: all payload methods (toMail, toArray, toBroadcast, toExpoPush, etc.) accept it as the first parameter by framework convention.
@DenTray What do you think this should look like?
Option 1. Pass $notifiable automatically when $value instanceof Notification, no API changes (no control over what gets passed)
'message' => ['toExpoPush()', 'toArray()']Option 2. Custom args per step (full control over args)
Step can carry explicit arguments:
'message' => [['toExpoPush()', [$notifiable]], 'toArray()']Or via callable:
'message' => [fn ($notification, $notifiable) => $notification->toExpoPush($notifiable)->toArray()]But then we need to decide: should callable and string syntax coexist, or should callable fully replace chains? The first option seems more reasonable - simple cases remain strings, while complex ones are implemented as callables.
There was a problem hiding this comment.
@DenTray I believe we can force the $notifiable arg. This solution will cover above 99% of real use-cases.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e756fc6bf2
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b2c098805f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…fixture assertion refs: #269
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 97bb55e00e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| } | ||
| } | ||
|
|
||
| $preparedActualData = json_decode(json_encode($actualData), true); |
There was a problem hiding this comment.
Reject failed notification JSON normalization
When a captured notification attribute or option result is not JSON-encodable—for example, a non-backed enum, malformed UTF-8 string, resource, or INF—json_encode() returns false and json_decode() silently converts that failure into null. Export mode can consequently create a null fixture, and later assertions against it can pass while discarding every sent notification; use JSON_THROW_ON_ERROR or explicitly fail/normalize unsupported values.
Useful? React with 👍 / 👎.
DenTray
left a comment
There was a problem hiding this comment.
The feature is not documented anywhere. NotificationsMockTrait and ReflectionTrait are new public API of the package, but neither readme.md nor documentation/traits.md mentions them — so assertNotificationsSent() ships invisible to anyone who does not read the diff.
What is missing:
documentation/traits.md— a## NotificationsMockTraitsection. The$optionschain DSL is the part that genuinely needs prose: the'method()'vs'property'step forms, the fact that$notifiableis passed as the first argument to every method call, the reserved keys (notification,channels,notifiable,locale), and a worked example. The wrapper pattern from #269 (assertPushNotificationSent,assertBroadcastNotificationSent) is the most useful example to show, since that is the intended consumption path.documentation/traits.md— a## ReflectionTraitsection, or a note that it is an internal helper not meant for direct use. Right now it is a public trait inRonasIT\Support\Traitswith a single consumer and no description, so its contract is undefined for users — in particular that it skips static and uninitialized properties, and that inherited private properties are not captured.readme.md— the## Usagelist links to the documentation sections; a new trait belongs in whatever index the project keeps up to date.MailsMockTraitis undocumented too, but that is an existing gap, not a reason to add a second one.documentation/migration.md— an entry for the globalNotification::fake()added insrc/Testing/TestCase.php: it stops notification channels from executing in consuming projects' tests, so tests asserting rows in thenotificationstable, or side effects of custom channels, would start failing. (For the record on what is not affected: directMail::to(...)->queue(...)still reachesMailFakeuntouched, and mail sent through notifications was never captured byMail::fake()anyway —MailChannelhandsMailer::send()a view rather than theMailable, andMailFake::sendMail()early-returns for non-Mailableinput.)
Convention going forward: the readme and the documentation files are expected to describe every feature of the package, and a PR that adds or changes public API updates them in the same PR. Docs that were made stale by a change count the same as docs that are missing.
| $this->fail("Notification {$notificationClass} doesn't have method '{$method}' required by options step '{$step}'."); | ||
| } | ||
|
|
||
| $value = $value->{$method}($notifiable); |
There was a problem hiding this comment.
$notifiable is passed unconditionally. Userland methods silently ignore the extra argument, but internal ones throw. Reproduction — a notification with getDate(): DateTimeImmutable:
options: ['ts' => ['getDate()', 'getTimestamp()']]ArgumentCountError: DateTimeImmutable::getTimestamp() expects exactly 0 arguments, 1 given
at src/Traits/NotificationsMockTrait.php:107
Chaining into DateTimeImmutable, ArrayObject or SplStack is realistic. Consider checking ReflectionMethod::getNumberOfParameters() and only passing $notifiable when the method accepts at least one argument.
|
|
||
| $value = $value->{$method}($notifiable); | ||
| } elseif (property_exists($value, $step)) { | ||
| $value = $value->$step; |
There was a problem hiding this comment.
property_exists() also returns true for private/protected members, so this line throws a raw PHP Error instead of the descriptive failure the docblock promises:
options: ['private_step' => ['channels']] // TestNotification::$channels is private readonlyError: Cannot access private property RonasIT\...\TestNotification::$channels
at src/Traits/NotificationsMockTrait.php:109
I agree with the decision that non-public access is out of scope — but then it should fail with a clear message rather than a fatal. Worth noting the inconsistency too: getObjectAttributes() does read private properties.
| $notificationClass = $notification::class; | ||
| $value = $notification; | ||
|
|
||
| foreach ($chain as $step) { | ||
| if (!is_object($value)) { | ||
| $type = get_debug_type($value); | ||
|
|
||
| $this->fail("Notification {$notificationClass} cannot resolve options step '{$step}' because the previous step returned a non-object value of type '{$type}'."); | ||
| } | ||
|
|
||
| if (str_ends_with($step, '()')) { | ||
| $method = substr($step, 0, -2); | ||
|
|
||
| if (!method_exists($value, $method)) { | ||
| $this->fail("Notification {$notificationClass} doesn't have method '{$method}' required by options step '{$step}'."); | ||
| } | ||
|
|
||
| $value = $value->{$method}($notifiable); | ||
| } elseif (property_exists($value, $step)) { | ||
| $value = $value->$step; | ||
| } else { | ||
| $this->fail("Notification {$notificationClass} doesn't have property '{$step}' required by options step '{$step}'."); | ||
| } | ||
| } |
There was a problem hiding this comment.
$notificationClass is captured once from the root notification but substituted into all three failure messages, including failures on nested objects further down the chain. With getModel(): TestNotifiable in the chain, options: ['name' => ['getModel()', 'nope']] currently reports:
Notification RonasIT\...\ClsNotification doesn't have property 'nope' required by options step 'nope'.
The property is missing on TestNotifiable, not on the notification — the message points at the wrong class. Taking the class from $value at the point of failure fixes it:
| $notificationClass = $notification::class; | |
| $value = $notification; | |
| foreach ($chain as $step) { | |
| if (!is_object($value)) { | |
| $type = get_debug_type($value); | |
| $this->fail("Notification {$notificationClass} cannot resolve options step '{$step}' because the previous step returned a non-object value of type '{$type}'."); | |
| } | |
| if (str_ends_with($step, '()')) { | |
| $method = substr($step, 0, -2); | |
| if (!method_exists($value, $method)) { | |
| $this->fail("Notification {$notificationClass} doesn't have method '{$method}' required by options step '{$step}'."); | |
| } | |
| $value = $value->{$method}($notifiable); | |
| } elseif (property_exists($value, $step)) { | |
| $value = $value->$step; | |
| } else { | |
| $this->fail("Notification {$notificationClass} doesn't have property '{$step}' required by options step '{$step}'."); | |
| } | |
| } | |
| $notificationClass = get_class($notification); | |
| $value = $notification; | |
| foreach ($chain as $step) { | |
| if (!is_object($value)) { | |
| $type = get_debug_type($value); | |
| $this->fail("Notification {$notificationClass} cannot resolve options step '{$step}' because the previous step returned a non-object value of type '{$type}'."); | |
| } | |
| $stepClass = get_class($value); | |
| if (str_ends_with($step, '()')) { | |
| $method = substr($step, 0, -2); | |
| if (!method_exists($value, $method)) { | |
| $this->fail("Class {$stepClass} doesn't have method '{$method}' required by options step '{$step}' of notification {$notificationClass}."); | |
| } | |
| $value = $value->{$method}($notifiable); | |
| } elseif (property_exists($value, $step)) { | |
| $value = $value->$step; | |
| } else { | |
| $this->fail("Class {$stepClass} doesn't have property '{$step}' required by options step '{$step}' of notification {$notificationClass}."); | |
| } | |
| } |
Now both classes are named — where the member is missing, and which notification the chain belongs to:
Class RonasIT\...\Models\TestNotifiable doesn't have property 'nope'
required by options step 'nope' of notification RonasIT\...\ClsNotification.
Notes on the diff:
- The
!is_object($value)branch intentionally keeps the root$notificationClass— a non-object has no class, and the type is already reported viaget_debug_type(), so referring to the notification is the useful context there. "Class {$stepClass} doesn't have method ..."matches the existing precedent inMailsMockTrait.php:219.$notification::classis also the only occurrence of that form insrc/; the other nine call sites all useget_class(), so this aligns with the codebase.- All three existing expectations (
"doesn't have method 'nonExistentMethod'","doesn't have property 'nonExistentProperty'",'returned a non-object value') remain substrings — verified green,OK (14 tests, 24 assertions), no test changes needed.
Separately, since the property branch only checks property_exists(), magic __get properties (Eloquent attributes) are never resolvable, so any chain reaching a model fails regardless of the message. Worth either supporting or stating explicitly in the docblock if it's intentional.
| { | ||
| if ($notifiable instanceof Model) { | ||
| return [ | ||
| $notifiable->getKeyName() => $notifiable->getKey(), |
There was a problem hiding this comment.
Reducing the notifiable to [keyName => key] discards the model class, while the outer aggregation is keyed only by the notification class. Two different notifiable classes sharing a key then produce indistinguishable entries. Reproduction with TestNotifiable#1 and a second OtherNotifiable#1:
{"RonasIT\\...\\ProbeNotification":[
{"notification":{"locale":null},"channels":["database"],"notifiable":{"id":1},"locale":null},
{"notification":{"locale":null},"channels":["database"],"notifiable":{"id":1},"locale":null}
]}A fixture written for User#1 will pass when the code actually notifies Admin#1. Including the notifiable class in the prepared data would close this.
| } | ||
| } | ||
|
|
||
| $preparedActualData = json_decode(json_encode($actualData), true); |
There was a problem hiding this comment.
json_decode(json_encode(...)) silently converts an encoding failure into null. Reproduction with a non-UTF-8 string returned by an option step:
ENCODED: false ERR: Malformed UTF-8 characters, possibly incorrectly encoded
DECODED: NULL
The assertion then compares null against the fixture with an unhelpful message, and in export mode a literal null is written to the fixture file. JSON_THROW_ON_ERROR or an explicit fail() would surface the real cause. Non-backed enums, resources and INF hit the same path.
| foreach ($notificationsByNotifiable as $notificationsByClass) { | ||
| foreach ($notificationsByClass as $notificationClass => $notifications) { | ||
| foreach ($notifications as $notification) { | ||
| $actualData[$notificationClass][] = $this->prepareNotificationFixtureData($notification, $options); |
There was a problem hiding this comment.
Because the fake groups by notifiable class and then by notifiable key, the order of entries under a notification class is grouping order, not send order. Sending A to user1, B to user2, then C to user1 produces A, C, B in the fixture (verified).
That is a surprising footgun for anyone asserting a sequence of notifications — worth documenting in the docblock, since a fixture that looks chronological is not.
| exportMode: true, | ||
| ); | ||
|
|
||
| $this->assertFileExists($this->getFixturePath('assert_notifications_sent_with_export.json')); |
There was a problem hiding this comment.
Two gaps compared to MailsMockTraitTest::testMailWithGlobalExportMode:
- The fixture is not unlinked first, so this assertion can pass on a file left over from an earlier run.
- Nothing verifies the exported content —
assertEqualsFixturecompares the data against the fixture it just exported, so the assertion is self-fulfilling. The mail test compares against a committedtest_mail_with_global_export_example.html; an equivalent committed reference here would make both export tests meaningful.
Same applies to testAssertNotificationsSentWithGlobalExportMode below.
|
|
||
| public function testAssertNotificationsSentWithAnonymousNotifiable(): void | ||
| { | ||
| Notification::route('mail', 'test@example.com')->notify(new TestNotification()); |
There was a problem hiding this comment.
Notification::route('mail', ...) is combined with a notification whose via() returns ['database']. On a real dispatch AnonymousNotifiable::route('database') throws InvalidArgumentException — the database channel does not support on-demand notifications. The fake does not enforce it, but the fixture then encodes a state that cannot occur in production. Using a mail-channel notification would keep the case realistic.
| $reservedKeys = ['notification', 'channels', 'notifiable', 'locale']; | ||
|
|
||
| foreach (array_keys($options) as $key) { | ||
| if (in_array($key, $reservedKeys, true)) { | ||
| $this->fail("Options field '{$key}' collides with a reserved key. Reserved keys are: " . implode(', ', $reservedKeys) . '.'); | ||
| } | ||
| } |
There was a problem hiding this comment.
foreach + in_array here can be a single key intersection. Besides being shorter, it reports all colliding keys at once instead of failing on the first one — with ['locale' => ..., 'channels' => ...] the developer currently needs two runs to learn about both.
| $reservedKeys = ['notification', 'channels', 'notifiable', 'locale']; | |
| foreach (array_keys($options) as $key) { | |
| if (in_array($key, $reservedKeys, true)) { | |
| $this->fail("Options field '{$key}' collides with a reserved key. Reserved keys are: " . implode(', ', $reservedKeys) . '.'); | |
| } | |
| } | |
| $reservedKeys = ['notification', 'channels', 'notifiable', 'locale']; | |
| $collisions = array_keys(array_intersect_key($options, array_flip($reservedKeys))); | |
| if (!empty($collisions)) { | |
| $this->fail(sprintf( | |
| "Options field '%s' collides with a reserved key. Reserved keys are: %s.", | |
| implode("', '", $collisions), | |
| implode(', ', $reservedKeys), | |
| )); | |
| } |
array_intersect_key is preferable to array_intersect(array_keys($options), $reservedKeys): it expresses the intent directly (intersecting option keys with reserved keys) and compares keys exactly, whereas array_intersect falls back to loose string comparison. Verified equivalent on edge cases — no collision, one, several, numeric-string keys, integer key 0.
The single-collision message is byte-identical to the current one, so testAssertNotificationsSentWithReservedOptionKey stays green. Two optional follow-ups, both out of scope for a one-click suggestion:
- move the list into a typed constant (
protected const array RESERVED_OPTION_KEYS = [...], matching the style ofPostgresDBTypeResolverandTestCase::REDIS_COUNT_DATABASES) so the docblock and the validation cannot drift apart; - use proper plural wording (
Options fields '...' collide with reserved keys) — that requires updating the expectation on line 141.
- resolve options chain via `ReflectionMethod::getNumberOfParameters()` so parameterless methods are called without arguments, chaining into internal classes like `DateTimeImmutable::getTimestamp()` no longer throws `ArgumentCountError` - cover the case with a `getDate()` step on `TestChainableNotification` Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- check `ReflectionMethod::isPublic()` and resolve properties through `get_object_vars()` so private and protected members, as well as uninitialized typed ones, fail the assertion instead of raising a raw PHP `Error`, dynamic properties stay resolvable - document that only public members are resolvable while the `notification` fixture key still exposes all the properties - cover non-public method, non-public property and uninitialized property steps Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- take the class from the current chain value instead of always naming the root notification, so a failure on a nested object points at that object while the message still names the notification being resolved - cover an unresolvable step on a nested object Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- include the class next to the primary key of a model notifiable and next to the public properties of any other one, so notifiables of different classes sharing a key no longer produce indistinguishable entries under the same notification class - document the shape of the `notifiable` key and the `prepareNotifiableFixtureData()` extension point - cover two notifiable classes sharing the primary key, update the existing fixtures Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- add `castToJsonStructure()` to `FixturesTrait`, it casts the data to the structure of a read back fixture with `JSON_THROW_ON_ERROR` and fails with the encoding error instead of silently comparing null and writing null to the fixture in export mode - assert the sent notifications through it, so non-UTF-8 strings, non-backed enums, resources and INF report the real cause - cover the cast itself and an unencodable option step value Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- state that the fake groups entries by notifiable class and key, so a notification sent to one notifiable, another one and the first one again lands out of the send order and the fixture is not a timeline - cover the grouping order with a fixture that locks it in Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- remove the exported fixture before the export tests, so a file left over from an earlier run can no longer make them pass - compare the export against a committed example fixture instead of the file it has just written, and turn the global export mode off before that comparison to keep the example intact Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- match the notification channel with the on-demand route, the database channel rejects anonymous notifiables on a real dispatch, so the previous fixture encoded a state unreachable in production Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- intersect the options with the reserved fields instead of failing on the first match, so all colliding fields are named in one run - move the reserved fields to the `RESERVED_NOTIFICATION_FIELDS` constant and name the check after the notification options it validates - cover a collision on two fields Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- add a `NotificationsMockTrait` section: the fixture structure, the order of the entries, the options chain steps with their resolution rules and reserved field names, and the per channel assertions a project declares in its own `TestCase` - add a `ReflectionTrait` section marking it an internal helper and stating that it skips static and uninitialized properties and does not capture the private properties of parent classes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- describe the feature next to the other package features and link it to the `NotificationsMockTrait` section of the traits documentation Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ee481d42c1
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
- describe that `TestCase` now fakes the notifications, so the channels stop executing and the tests asserting the `notifications` table rows or the custom channel side effects have to assert the notifications themselves, with a way to restore the real dispatch when needed - link the new section from the readme migration guides Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- keep the options shape, a chain example covering both step forms and the resolution rules, and point to the traits documentation for the fixture structure, the entry order and the channel wrappers - refer to `RESERVED_NOTIFICATION_FIELDS` instead of listing the reserved names, they had already drifted apart once Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- send a notification with an explicit id and assert the fixture without it, the fake assigns a random uuid to every notification, so keeping the id would make every fixture unstable Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- accept a step exposed through `__isset()` or held in the attributes of an Eloquent model, a chain into a model attribute used to fail with a missing property message while the same access works in the code - keep the loud failures for a misspelled, non-public and uninitialized step - cover a chain into a model attribute and a misspelled one Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- inline the json round trip into `assertNotificationsSent()` instead of exposing it as a `FixturesTrait` method, the assertion is its only consumer and the trait is mixed into every project `TestCase` - keep the failure message specific to the sent notifications, its coverage stays in `NotificationsMockTraitTest` Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- nest the notifiable data under an `attributes` key next to the class, a primary key or a public property named `class` used to overwrite the class and make notifiables of different types indistinguishable again - update the fixtures and the traits documentation to the new shape Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ee481d4 to
34d3e81
Compare
- keep in the phpdoc only what the call site needs, move the details of the step resolution to the traits documentation, they were described in both places and had already drifted - replace the outdated claim that only public members are resolvable, magic properties and model attributes resolve as well Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- give the assertion its own heading with a typed signature, so the phpdoc can link straight at it, and point that link at the rendered documentation instead of an unresolvable relative path - say why the notification id never reaches the fixture, unfold the sentence about the notifiable attributes and drop the bullet repeating what the paragraph below already says about non-public members Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@DenTray All the review comments above have been addressed. Added beyond the review scope: magic property steps in the options chain
public function __construct(public readonly Order $order) {}options: ['order_status' => ['order', 'status']]
The check now lives in The loud failures are unchanged — a misspelled step, a non-public member and an uninitialized typed property still fail with a message naming the class the step was resolved on, both covered by tests. Documented in |
refs: #269
Improvements over the reference implementation: