Tests¶
The analyzer types PHPUnit assertions, mocks and Prophecy doubles. The checks report assertions
that always pass, doubles that a type or a docblock gets wrong, and mistakes in Drupal's test
classes. The phpunit plugin does the PHPUnit and Prophecy work and depends on nothing in Drupal.
The drupal plugin has the checks for Drupal's test classes.
Emptiness assertions¶
PHPUnit 11 puts @phpstan-assert on assertTrue(), assertFalse(), assertNull(),
assertNotNull(), assertInstanceOf(), assertSame() and the assertIs*() family, and Mago reads
it, so those assertions narrow the value on their own. assertNotEmpty() and assertEmpty() don't
have such a tag, and Drupal's tests use those two a lot. A loaded entity then stays nullable for the
rest of the test, and every call on it is reported.
The phpunit plugin narrows the value for these two: after assertNotEmpty() it isn't empty, and
after assertEmpty() it is empty.
Not narrowed:
- a
Countable,Traversableor plainobjectvalue afterassertEmpty(). PHPUnit counts such an object, so an empty collection passes the assertion.
$node = Node::load(1);
$this->assertNotEmpty($node);
$node->label(); // narrowed, no possible-method-access-on-null
Mock unions¶
Drupal's older tests document a mock as @var X|MockObject, from before PHP had intersection types.
Mago checks each half of a union on its own, so expects() is missing on X, and the call comes
back as mixed, which loses the type of the rest of the chain. phpstan-phpunit reads the union as
X&MockObject. A hook for that doesn't exist in Mago, and a provider type on the property would
count as a magic property, so the phpunit plugin types the two methods on X instead.
Typed, as InvocationMocker:
expects()with one argument, which is checked by Mago againstInvocationOrder;method()with a string literal that names a method of the type.
The plugin answers only when the type doesn't declare the method, and it sees that one type, not the union.
Not typed:
- PHPUnit's own types, so
expects()on theStubhalf of anX|Stubunion or on aMockBuilderis still reported.
/** @var \Drupal\Core\Extension\ModuleHandlerInterface|\PHPUnit\Framework\MockObject\MockObject */
protected $moduleHandler;
$this->moduleHandler->expects($this->once())->method('invokeAll')->willReturn([]); // no issues
Since the plugin never sees the union, it also answers when the type is X alone.
phpunit/mock-call-on-plain-type reports those calls.
A union mock passed where the mocked type goes is accepted too. Mago checks each member of the union
against the parameter and reports the MockObject half as possibly-invalid-argument. The plugin
drops that report when every other member of the union is the expected type or a class that fits
it. A null member is left to possibly-null-argument, and any other member keeps the report.
Core 11.4's UnitTestCase::getStringTranslationStub() returns such a union too.
$this->worker->setStringTranslation($this->getStringTranslationStub()); // no issues
The other direction stays reported. Calling invokeAll() on the union still reports it missing on
MockObject, once per call and with the type of the call intact, since the plugin never sees X
from that side. A typed chain also lets Mago check calls that stay unchecked on mixed, such as
with() after method() on a stub, which PHPUnit's InvocationStubber doesn't have.
Prophecy calls¶
$prophecy->getFoo() on an ObjectProphecy<Foo> goes through __call(), which returns a
MethodProphecy. Mago reports every such call as undocumented, since the class doesn't have a
@method tag for it. The phpunit plugin types the call as a MethodProphecy when the prophesized
class declares the method, or when the class isn't known. A method that the class lacks stays
reported, since Prophecy throws for it.
$storage = $this->prophesize(EntityStorageInterface::class);
$storage->load(1)->willReturn($node); // MethodProphecy, no non-documented-method
$storage->notAMethod(); // still reported
A prophecy documented as a union with its class, such as @var Foo|ObjectProphecy, is typed half by
half. The bare ObjectProphecy doesn't name a class, so its half gets a MethodProphecy. A
ProphecyInterface half doesn't have such a method at all. The Foo half gets the result of the
real method, although a prophecy is never a Foo.
phpunit/prophecy-union reports the docblock.
willImplement() and willExtend() declare @phpstan-this-out static<T&U>, which isn't applied by
Mago (mago#2395). The plugin adds the type
that they name to the prophecy: through the return type for a chain, and by narrowing the variable
for a call on one. A prophecy told to implement an interface inside an if is a union afterwards,
and a later call adds its type to each member.
$block = $this->prophesize(BlockPluginInterface::class);
$block->willImplement(PreviewFallbackInterface::class);
$block->getPreviewFallbackString()->willReturn('Placeholder'); // found on the added interface
When an interface extends Traversable but neither Iterator nor IteratorAggregate, Prophecy
makes its double an Iterator. The plugin accepts current(), key(), next(), rewind() and
valid() on such a prophecy.
$items = $this->prophesize(FieldItemListInterface::class);
$items->valid()->willReturn(TRUE, FALSE); // added by Prophecy, no non-documented-method
phpunit/redundant-assertion¶
- Level: warning
- Source: phpstan-phpunit
phpstan-phpunit reports a PHPUnit assertion that always passes, given the type of the value. Such an assertion doesn't test anything.
Reports:
- an assertion that the type of its value already guarantees, such as
assertTrue(TRUE), orassertInstanceOf()on a value already typed as that class.
The check covers assertInstanceOf(), assertNotNull(), assertNull(), assertTrue(),
assertFalse(), assertNotTrue(), assertNotFalse(), and assertIsArray(), assertIsBool(),
assertIsFloat(), assertIsInt(), assertIsNumeric(), assertIsObject(), assertIsScalar()
and assertIsString(). A type from a docblock counts, and so does one supplied by the plugins, such
as the class of a service from a services file.
Not reported:
Mago types some values more narrowly than the code behaves, and these are left alone:
$this->name, unless the native type of the property passes the assertion too. Mago keeps the narrowed type of a property across calls that may change it, such as a callback that sets$this->fired. A property typed only by@varnever counts: PHP doesn't check the tag, and the property is NULL until something is assigned. A magic property, which doesn't have a declaration, never counts either;- another object's property,
$other->name, since the check doesn't know the class of that object; - a variable that an inline
@vartag names in the same method. The tag is the test's own claim, often over aload()that can return NULL, and the assertion is what checks it at runtime; - a variable that a
staticorglobalstatement in the file declares. Its value can come from an earlier call or change elsewhere, which isn't followed by Mago; $items[0], since Mago'sArrayAccessstub typesoffsetGet()without null, while an empty field item list returns null;isset(), which tests__isset()orArrayAccessrather than a type;- any of these behind a
!; - an assertion that can never pass. When the value is a variable, Mago reports it as
impossible-type-comparison, and otherwise it isn't reported.
Example
// Reported.
namespace Drupal\Tests\example\Unit;
use PHPUnit\Framework\TestCase;
final class ExampleTest extends TestCase {
public function testNames(): void {
$names = $this->names();
$this->assertIsArray($names);
}
/**
* @return list<string>
*/
private function names(): array {
return ['a', 'b'];
}
}
// Fine.
namespace Drupal\Tests\example\Unit;
use PHPUnit\Framework\TestCase;
final class ExampleTest extends TestCase {
public function testNames(): void {
$names = $this->names();
$this->assertNotEmpty($names);
}
/**
* @return list<string>
*/
private function names(): array {
return ['a', 'b'];
}
}
Compared with phpstan-phpunit
- phpstan-phpunit makes PHPStan read more assertions, such as
assertSame(),assertCount(),assertEmpty()andassertArrayHasKey(), and theirNotforms, so PHPStan reports those when they always pass too. - PHPStan also reports an assertion that can never pass, whatever the value is.
- PHPStan forgets the narrowed type of a property after any call that may change it. Mago
forgets it only after a method call on
$this, such as$this->change(). It keeps it after a function call, a static call and a call on another object, such as a dispatcher that runs a callback which sets the property. So the check asks for the native type of the property instead.
phpunit/mock-call-on-plain-type¶
- Level: warning
- Source: PHPStan
A mock typed as the mocked class alone, without MockObject, doesn't have expects() or method()
for Mago. The mock union types answer for such a call all the same, since they see
one type at a time. This check sees the whole type of the value and reports the call: the value is a
mock at runtime, but the property, parameter or return type that it comes from leaves MockObject
out. PHPStan reports the same calls as undefined methods.
Reports:
- an
expects()ormethod()call on a value whose type doesn't name a PHPUnit class or declare the method. The check takes anexpects()call with one argument, and amethod()call that names a method of the type.
Not reported:
- a value whose type includes a PHPUnit type, such as
X|MockObjectorX&MockObject; - any other call, which stays Mago's own report of a missing method.
Example
namespace Drupal\example;
interface GreeterInterface {
public function greet(): string;
}
// Reported.
namespace Drupal\Tests\example\Unit;
use Drupal\example\GreeterInterface;
use PHPUnit\Framework\TestCase;
final class GreeterTest extends TestCase {
/**
* @var \Drupal\example\GreeterInterface
*/
protected $greeter;
protected function setUp(): void {
$this->greeter = $this->createMock(GreeterInterface::class);
}
public function testGreet(): void {
$this->greeter->expects($this->once())->method('greet');
}
}
// Fine.
namespace Drupal\Tests\example\Unit;
use Drupal\example\GreeterInterface;
use PHPUnit\Framework\TestCase;
final class GreeterTest extends TestCase {
/**
* @var \Drupal\example\GreeterInterface&\PHPUnit\Framework\MockObject\MockObject
*/
protected $greeter;
protected function setUp(): void {
$this->greeter = $this->createMock(GreeterInterface::class);
}
public function testGreet(): void {
$this->greeter->expects($this->once())->method('greet');
}
}
Compared with PHPStan
PHPStan reports these calls as calls to undefined methods, as it reports any other MockObject
method called on such a value. The check reports only expects() and method(), and Mago
reports the other calls itself.
phpunit/prophecy-union¶
- Level: warning
- Source: Prophecy's own types
A prophecy is never an instance of the class that it prophesizes, so a type that unions the two is
wrong. Calls on such a value, and the calls chained on them, are reported on one half or the other,
far from the docblock that causes them. The check reports the docblock once, with the type to use
instead, ObjectProphecy<Foo>.
Reports:
- a docblock type that unions
ProphecyInterfaceorObjectProphecywith another class. The check reads the@var,@paramand@returntags of properties, parameters and return types in a class or a trait.
Example
// Reported.
namespace Drupal\Tests\example\Unit;
use PHPUnit\Framework\TestCase;
final class ExampleTest extends TestCase {
/**
* @var \Drupal\Core\Extension\ModuleHandlerInterface|\Prophecy\Prophecy\ProphecyInterface
*/
protected $moduleHandler;
}
// Fine.
namespace Drupal\Tests\example\Unit;
use PHPUnit\Framework\TestCase;
final class ExampleTest extends TestCase {
/**
* @var \Prophecy\Prophecy\ObjectProphecy<\Drupal\Core\Extension\ModuleHandlerInterface>
*/
protected $moduleHandler;
}
Compared with phpstan-drupal and phpstan-phpunit
Neither has a check for these docblocks.
drupal/test-modules-visibility¶
- Level: error
- Source: phpstan-drupal
- Ports: phpstan-drupal's
TestClassesProtectedPropertyModulesRule
phpstan-drupal reports a test class whose $modules property is public. Drupal's test bases declare
it protected, see the change record.
Reports:
- a public
$modulesproperty, static or not, typed or not, declared in a class that descends from PHPUnit'sTestCase.
Not reported:
- a
$modulesthat the class inherits from a parent or gets from a trait. The class that declares it is the one to fix.
Compared with phpstan-drupal
phpstan-drupal also reports a $modules declared with var, which PHP makes public too, and a
public $modules declared by a trait, once for each test class that uses the trait.
drupal/browser-test-default-theme¶
- Level: error
- Source: phpstan-drupal
- Ports: phpstan-drupal's
BrowserTestBaseDefaultThemeRule
phpstan-drupal reports a functional test that doesn't set $defaultTheme. When the install profile
of the test doesn't install a theme, Drupal throws while it sets up the test.
Reports:
- a browser test on a themeless profile that doesn't set
$defaultTheme. The test is a concreteBrowserTestBasedescendant whose name ends inTest, and the property counts when it is set on the class, on a base class, or by a trait that either of them uses.
The check finds the test's $profile the same way, on the class, a base class or a trait. A profile
is themeless when it doesn't ship a system.theme config in config/sync or config/install,
which is checked by Drupal when the test runs. Profiles are read from core, from the site's
profiles directory, and from the tests/profiles directories of core's modules and of extensions.
A profile not found there counts as themeless when it is one of core's themeless test profiles, such
as testing.
Not reported:
- a class that overrides
installDefaultThemeFromClassProperty(), itself, through a base class or through a trait, since the override may set the theme at runtime; - a class that assigns
$this->defaultThemeitself, such as insetUp(); - update path tests, which install from a database dump;
- a test on a
NULLorFALSEprofile, which installs from existing configuration and takes its theme; - a profile that isn't a string literal, such as one read from a constant.
Example
// Reported.
namespace Drupal\Tests\example\Functional;
use Drupal\Tests\BrowserTestBase;
final class ExampleTest extends BrowserTestBase {
public function testPage(): void {}
}
// Fine.
namespace Drupal\Tests\example\Functional;
use Drupal\Tests\BrowserTestBase;
final class ExampleTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
public function testPage(): void {}
}
Compared with phpstan-drupal
- phpstan-drupal checks a class only in a
FunctionalorFunctionalJavascriptnamespace, as the fourth part of a module test's namespace, or in core'sFunctionalTestsandFunctionalJavascriptTests. The check covers everyBrowserTestBasedescendant. - phpstan-drupal counts only core's seven themeless test profiles as themeless, and leaves a test on any other profile alone. The check reads the config of each profile from disk.
- phpstan-drupal skips the two installer test bases that install from existing configuration by
class name. The check skips a test on a
NULLprofile, which is what those bases set, so it also skips other tests on aNULLprofile, which are reported by phpstan-drupal. - phpstan-drupal reports a class that sets the theme in
setUp()or that overridesinstallDefaultThemeFromClassProperty(). The check leaves both alone.
drupal/component-test-core-base¶
- Level: error
- Source: Drupal core's PHPStan rules
- Ports: core's
ComponentTestDoesNotExtendCoreTestrule
Core's component tests run without Drupal, so they can't extend a test base that boots Drupal or needs its classes. Core's own PHPStan rules report a component test that does.
Reports:
- a class in the
Drupal\Tests\Componentnamespace that extendsUnitTestCase,KernelTestBase,BuildTestBaseorBrowserTestBase, directly or through its parents.
WebDriverTestBase counts, since it extends BrowserTestBase. The report is on the class whose own
parent lies outside the component tests. So a component test base that extends a core one is
reported once, and not again on the tests below it.
Compared with core's rule
Core's rule reports every component test below such a base too, not only the base.
drupal/test-class-name¶
- Level: error
- Source: phpstan-drupal
- Ports: phpstan-drupal's
TestClassSuffixNameRule, the part that needs the class's ancestry
PHPUnit runs only the files named *Test.php, and so do phpunit -c core and run-tests.sh, so a
test class whose name doesn't end in Test never runs. The lint rule
drupal/test-class-suffix reports most of
them from the parent's name, with no types. The check reports the ones that the lint rule can't
tell apart from other classes.
Reports:
- a concrete class that descends from PHPUnit's
TestCase, whose name doesn't end inTest, and thatdrupal/test-class-suffixdoesn't take for a test. That is a class outside the lint rule's test directories, such as one directly intests/srcor insrc, a class whose parent's name doesn't end inTest,TestBaseorTestCase, and a class whose parent is declared in the same file without such a parent of its own.
Not reported:
- a class that the lint rule reports, so a class gets one report when both run;
- an abstract class and an anonymous class.
Example
namespace Drupal\Tests\example\Unit;
use Drupal\Tests\UnitTestCase;
abstract class ExampleUnitBase extends UnitTestCase {
}
// Reported.
namespace Drupal\Tests\example\Unit;
final class ExampleChecks extends ExampleUnitBase {
}
// Fine.
// The class and its file are named ExampleChecksTest.
namespace Drupal\Tests\example\Unit;
final class ExampleChecksTest extends ExampleUnitBase {
}
Compared with phpstan-drupal
phpstan-drupal reports every such class from one rule. The check leaves the classes that the
lint rule reports to it, so a project that runs mago analyze without mago lint doesn't see
those.