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:
attributeValue(..., Test::class) returns null, \$specials['test'] stays unset.
\$is_test evaluates to false, the method is treated as a non-test.
\$codebase->methodExists(\$declaring_method_id, 'PHPUnit\Framework\TestSuite::run') is never called.
- 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)
Summary
When a test class consumes a trait whose test methods are marked with the
#[Test]attribute, Psalm reportsPossiblyUnusedMethodfor each inherited method on every consuming class, but only when the consuming class does not have its ownuse PHPUnit\Framework\Attributes\Test;import.Reproduction
The shared trait (note the
use ...\Test;import lives here):The consuming test class (no
Testimport):Running Psalm produces:
Adding
use PHPUnit\Framework\Attributes\Test;toJsonSerializerTestmakes the error go away.Detection matrix
public function testFoo()(name based)/** @test */ public function it_foo()(docblock)#[Test] public function it_foo()in trait, consumer importsTest#[Test] public function it_foo()in trait, consumer does not importTestRoot cause
In
src/Hooks/TestCaseHandler.php::afterStatementAnalysis:getAttributeSpecials → attributeValueresolves the attribute name withType::getFQCLNFromString(\$attribute->name->toString(), \$aliases). For a#[Test]attribute written in the trait,\$attribute->name->toString()returns the literalTest. Resolving it against the consuming class's aliases (which lack the import) yields the wrong FQCN (consumer's namespace +Test), so theTestattribute is not recognized.Consequences:
attributeValue(..., Test::class)returnsnull,\$specials['test']stays unset.\$is_testevaluates tofalse, the method is treated as a non-test.\$codebase->methodExists(\$declaring_method_id, 'PHPUnit\Framework\TestSuite::run')is never called.PossiblyUnusedMethod.Other paths mask the bug:
testFooshort-circuits via the name check, and/** @test */goes throughgetDocblockSpecialswhich doesn't touch alias resolution.Suggested fix
Use the declaring class's own aliases when reading specials for an inherited method:
Psalm\Storage\ClassLikeStorage::\$aliases(?Aliases) holds the file context captured when the trait was scanned, which is whatType::getFQCLNFromStringneeds to mapTesttoPHPUnit\Framework\Attributes\Test. The?? \$aliasesfallback preserves current behavior when the declaring storage has no recorded aliases.Related, possibly worth a separate issue
The same
is_traitbranch 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_nodestays as the consuming class's node,getMethod()returnsnullat 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
#[Test]attribute)