Skip to content

PossiblyUnusedMethod for inherited #[Test] methods when consuming class doesn't import Test attribute #158

@alies-dev

Description

@alies-dev

Summary

When a test class consumes a trait whose test methods are marked with the #[Test] attribute, Psalm reports PossiblyUnusedMethod for each inherited method on every consuming class, but only when the consuming class does not have its own use PHPUnit\Framework\Attributes\Test; import.

Reproduction

The shared trait (note the use ...\Test; import lives here):

<?php declare(strict_types=1);

namespace App\Tests;

use PHPUnit\Framework\Attributes\Test;

trait SerializerContract
{
    #[Test]
    public function it_serializes_a_value(): void
    {
        $this->assertIsString('hello');
    }
}

The consuming test class (no Test import):

<?php declare(strict_types=1);

namespace App\Tests;

use PHPUnit\Framework\TestCase;

final class JsonSerializerTest extends TestCase
{
    use SerializerContract;
}

Running Psalm produces:

ERROR: PossiblyUnusedMethod
Cannot find any calls to method App\Tests\JsonSerializerTest::it_serializes_a_value

Adding use PHPUnit\Framework\Attributes\Test; to JsonSerializerTest makes the error go away.

Detection matrix

Trigger Detected?
public function testFoo() (name based) yes
/** @test */ public function it_foo() (docblock) yes
#[Test] public function it_foo() in trait, consumer imports Test yes
#[Test] public function it_foo() in trait, consumer does not import Test no, bug

Root cause

In src/Hooks/TestCaseHandler.php::afterStatementAnalysis:

\$aliases = \$statements_source->getAliases();    // line 124, aliases of the consuming class file
...
foreach (\$class_storage->declaring_method_ids as \$method_name_lc => \$declaring_method_id) {
    ...
    if (\$declaring_class_storage->is_trait) {
        \$declaring_class_node = \$codebase->classlikes->getTraitNode(...);   // trait AST is fetched correctly
    }
    ...
    \$stmt_method = \$declaring_class_node->getMethod(\$declaring_method_name);
    ...
    \$specials = self::getSpecials(\$stmt_method, \$aliases);   // line 162, wrong alias context

getAttributeSpecials → attributeValue resolves the attribute name with Type::getFQCLNFromString(\$attribute->name->toString(), \$aliases). For a #[Test] attribute written in the trait, \$attribute->name->toString() returns the literal Test. Resolving it against the consuming class's aliases (which lack the import) yields the wrong FQCN (consumer's namespace + Test), so the Test attribute is not recognized.

Consequences:

  1. attributeValue(..., Test::class) returns null, \$specials['test'] stays unset.
  2. \$is_test evaluates to false, the method is treated as a non-test.
  3. \$codebase->methodExists(\$declaring_method_id, 'PHPUnit\Framework\TestSuite::run') is never called.
  4. Psalm has no recorded call to the method, hence PossiblyUnusedMethod.

Other paths mask the bug: testFoo short-circuits via the name check, and /** @test */ goes through getDocblockSpecials which doesn't touch alias resolution.

Suggested fix

Use the declaring class's own aliases when reading specials for an inherited method:

\$method_aliases = \$declaring_class_storage->aliases ?? \$aliases;
\$specials = self::getSpecials(\$stmt_method, \$method_aliases);

Psalm\Storage\ClassLikeStorage::\$aliases (?Aliases) holds the file context captured when the trait was scanned, which is what Type::getFQCLNFromString needs to map Test to PHPUnit\Framework\Attributes\Test. The ?? \$aliases fallback preserves current behavior when the declaring storage has no recorded aliases.

Related, possibly worth a separate issue

The same is_trait branch is the only place where the plugin swaps the AST node to the declaring class's node. For methods inherited from a parent class (not a trait), \$declaring_class_node stays as the consuming class's node, getMethod() returns null at line 158, and the loop body is skipped entirely. So inherited test methods coming from a parent class are also not marked as used, regardless of how they're declared.

Environment

  • psalm/psalm-plugin-phpunit: latest from master
  • vimeo/psalm: any recent version
  • PHPUnit: 10+ (using #[Test] attribute)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions