Skip to content

feat(testing): add ExpectTestStatus, ExpectAssertionsCount, ExpectTestResultAttribute (#36)#264

Open
rossaddison wants to merge 6 commits into
php-testo:1.xfrom
rossaddison:feat/36-expect-test-attributes
Open

feat(testing): add ExpectTestStatus, ExpectAssertionsCount, ExpectTestResultAttribute (#36)#264
rossaddison wants to merge 6 commits into
php-testo:1.xfrom
rossaddison:feat/36-expect-test-attributes

Conversation

@rossaddison

Copy link
Copy Markdown
Contributor

Summary

Closes #36.

Adds three PHP attributes that let a test method assert properties of the inner stub TestResult returned by TestRunner::runTest(), without writing explicit assertion code in the test body:

  • #[ExpectTestStatus(Status::X)] — asserts stubResult->status
  • #[ExpectAssertionsCount(N)] — asserts stubResult->summary->metric('assertions')
  • #[ExpectTestResultAttribute('key')] — asserts stubResult->getAttribute('key') !== null (repeatable)

How it works

ExpectInterceptor (registered automatically by InjectPlugin) runs at ORDER_CLOSE_TO_TEST - 1. It reads the expect attributes from $info->testDefinition->reflection, calls $next() to execute the outer test, extracts $outerResult->result as the stub TestResult, then validates each declared expectation. Any mismatches are combined into a single Status::Failed with one RuntimeException. Pre-existing outer failures (or non-terminal statuses) are passed through untouched.

Files added

File Purpose
core/Testing/Attribute/ExpectTestStatus.php Attribute class
core/Testing/Attribute/ExpectAssertionsCount.php Attribute class
core/Testing/Attribute/ExpectTestResultAttribute.php Repeatable attribute class
core/Testing/Internal/ExpectInterceptor.php Interceptor implementation

Files modified

File Change
core/Testing/InjectPlugin.php Registers ExpectInterceptor

Test plan

  • 11 unit tests in tests/Core/Testing/Unit/ExpectInterceptorTest.php — all pass
  • Psalm: new files 100% clean (2 pre-existing errors in ChannelRenderer.php unrelated)
  • Full Core/Testing/Unit suite: 15/15 passed

🤖 Generated with Claude Code

…tResultAttribute (php-testo#36)

Three PHP attributes allow a test that uses TestRunner::runTest() to
assert properties of the inner stub TestResult without writing explicit
assertion code:

- #[ExpectTestStatus(Status::X)]   — validates stub.status
- #[ExpectAssertionsCount(N)]      — validates stub.summary.metric('assertions')
- #[ExpectTestResultAttribute(K)]  — validates stub.getAttribute(K) is not null (repeatable)

ExpectInterceptor reads these from the test method's reflection, runs the
test, extracts outerResult->result as the stub TestResult, and converts any
mismatches into a single Status::Failed with a combined message. Pre-existing
outer failures are preserved untouched. Registered automatically via InjectPlugin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@rossaddison
rossaddison requested a review from a team as a code owner July 6, 2026 14:45
github-actions Bot and others added 3 commits July 6, 2026 14:46
…ime return type

Style::dim() worked correctly with any string (empty or not) but declared
@param non-empty-string, which Psalm flagged at every call site where a plain
string was passed. Removed the over-restrictive annotation.

ChannelRenderer::formatTime() claimed @return non-empty-string with a
/** @var non-empty-string */ inline cast, which Psalm 7 does not accept.
Replaced date() with integer arithmetic + sprintf so the implementation is
cleaner, and removed the annotation since Psalm 7 does not narrow sprintf
to non-empty-string for this version.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

rossaddison and others added 2 commits July 6, 2026 18:39
…is mirrored

tests/Application/Stub/EmptyRun/ is intentionally empty — it is the test
fixture for EmptyRunTest, which asserts that a Testo run over an empty
directory yields Status::Risky with zero tests collected.

Git does not track empty directories, and bin/build-phpunit.php only copies
*.php files when populating the tests/PhpUnit/ mirror, so the mirror never
contained tests/PhpUnit/Application/Stub/EmptyRun/. The mirrored EmptyRunTest
resolved __DIR__ . '/../../Stub/EmptyRun' to that missing path and threw
InvalidArgumentException: File or directory not found — aborting Infection's
initial PHPUnit test run on every CI push to 1.x.

Add .placeholder.php (no namespace, no classes, no tests) to the source
directory. The build script copies it verbatim into the mirror, which creates
the required directory. Testo's FinderConfig still discovers zero tests there,
so Status::Risky is reported and the assertion holds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t coverage

ExpectAssertionsCount, ExpectTestStatus, and ExpectTestResultAttribute each had
0% coverage per Codecov, despite ExpectInterceptorTest exercising every
constructor via newInstance(). Testo's codecov plugin scopes coverage per test
to the classes named in #[Covers(...)] on that test, so lines executed by a
test are only credited to files the test explicitly declares — and this test
only declared #[Covers(ExpectInterceptor::class)].

Add #[Covers(...)] for the three attribute classes so their already-exercised
constructors are credited.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Testing package

1 participant