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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions