diff --git a/.coveralls.yml b/.coveralls.yml
deleted file mode 100644
index 267998da..00000000
--- a/.coveralls.yml
+++ /dev/null
@@ -1,3 +0,0 @@
-service_name: travis-ci
-coverage_clover: tests/tmp/clover.xml
-json_path: tests/tmp/coveralls.json
diff --git a/.gitattributes b/.gitattributes
index 18e14aad..aae3fd19 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,11 @@
+*.php text eol=lf
+*.stub linguist-language=PHP
+*.neon linguist-language=YAML
+
+/.* export-ignore
/tests export-ignore
+/tmp export-ignore
+/Makefile export-ignore
+/phpstan.neon export-ignore
+/phpstan-baseline.neon export-ignore
+/phpunit.xml export-ignore
diff --git a/.github/renovate.json b/.github/renovate.json
new file mode 100644
index 00000000..d3f5961e
--- /dev/null
+++ b/.github/renovate.json
@@ -0,0 +1,19 @@
+{
+ "extends": [
+ "config:base",
+ "schedule:weekly"
+ ],
+ "rangeStrategy": "update-lockfile",
+ "packageRules": [
+ {
+ "matchPaths": ["+(composer.json)"],
+ "enabled": true,
+ "groupName": "root-composer"
+ },
+ {
+ "matchPaths": [".github/**"],
+ "enabled": true,
+ "groupName": "github-actions"
+ }
+ ]
+}
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 00000000..2df99361
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,299 @@
+# https://help.github.com/en/categories/automating-your-workflow-with-github-actions
+
+name: "Build"
+
+on:
+ pull_request:
+ push:
+ branches:
+ - "2.0.x"
+
+concurrency:
+ group: build-${{ github.head_ref || github.run_id }} # will be canceled on subsequent pushes in pull requests but not branches
+ cancel-in-progress: true
+
+jobs:
+ lint:
+ name: "Lint"
+ runs-on: "ubuntu-latest"
+
+ strategy:
+ matrix:
+ php-version:
+ - "7.4"
+ - "8.0"
+ - "8.1"
+ - "8.2"
+ - "8.3"
+ - "8.4"
+ - "8.5"
+
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v6
+
+ - name: "Install PHP"
+ uses: "shivammathur/setup-php@v2"
+ with:
+ coverage: "none"
+ php-version: "${{ matrix.php-version }}"
+
+ - name: "Validate Composer"
+ run: "composer validate"
+
+ - name: "Install dependencies"
+ run: "composer install --no-interaction --no-progress"
+
+ - name: "Lint"
+ run: "make lint"
+
+ coding-standard:
+ name: "Coding Standard"
+
+ runs-on: "ubuntu-latest"
+
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v6
+
+ - name: "Checkout build-cs"
+ uses: actions/checkout@v6
+ with:
+ repository: "phpstan/build-cs"
+ path: "build-cs"
+ ref: "2.x"
+
+ - name: "Install PHP"
+ uses: "shivammathur/setup-php@v2"
+ with:
+ coverage: "none"
+ php-version: "8.2"
+
+ - name: "Validate Composer"
+ run: "composer validate"
+
+ - name: "Install dependencies"
+ run: "composer install --no-interaction --no-progress"
+
+ - name: "Install build-cs dependencies"
+ working-directory: "build-cs"
+ run: "composer install --no-interaction --no-progress"
+
+ - name: "Lint"
+ run: "make lint"
+
+ - name: "Coding Standard"
+ run: "make cs"
+
+ tests:
+ name: "Tests"
+ runs-on: "ubuntu-latest"
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php-version:
+ - "7.4"
+ - "8.0"
+ - "8.1"
+ - "8.2"
+ - "8.3"
+ - "8.4"
+ - "8.5"
+ dependencies:
+ - "lowest"
+ - "highest"
+ phpunit-version:
+ - "^9.5"
+ - "^10.5"
+ - "^11.5"
+ - "^12.0.9"
+ exclude:
+ - php-version: "7.4"
+ phpunit-version: "^10.5"
+ - php-version: "8.0"
+ phpunit-version: "^10.5"
+ - php-version: "7.4"
+ phpunit-version: "^11.5"
+ - php-version: "8.0"
+ phpunit-version: "^11.5"
+ - php-version: "8.1"
+ phpunit-version: "^11.5"
+ - php-version: "7.4"
+ phpunit-version: "^12.0.9"
+ - php-version: "8.0"
+ phpunit-version: "^12.0.9"
+ - php-version: "8.1"
+ phpunit-version: "^12.0.9"
+ - php-version: "8.2"
+ phpunit-version: "^12.0.9"
+
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v6
+
+ - name: "Install PHP"
+ uses: "shivammathur/setup-php@v2"
+ with:
+ coverage: "none"
+ php-version: "${{ matrix.php-version }}"
+
+ - name: "Require specific PHPUnit version"
+ run: "composer require --dev phpunit/phpunit:${{ matrix.phpunit-version }}"
+
+ - name: "Install lowest dependencies"
+ if: ${{ matrix.dependencies == 'lowest' }}
+ run: "composer update --prefer-lowest --no-interaction --no-progress"
+
+ - name: "Install highest dependencies"
+ if: ${{ matrix.dependencies == 'highest' }}
+ run: "composer update --no-interaction --no-progress"
+
+ - name: "Tests"
+ run: "make tests"
+
+ static-analysis:
+ name: "PHPStan"
+ runs-on: "ubuntu-latest"
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php-version:
+ - "7.4"
+ - "8.0"
+ - "8.1"
+ - "8.2"
+ - "8.3"
+ - "8.4"
+ - "8.5"
+ dependencies:
+ - "lowest"
+ - "highest"
+ phpunit-version:
+ - "^9.5"
+ - "^10.5"
+ - "^11.5"
+ - "^12.0.9"
+ exclude:
+ - php-version: "7.4"
+ phpunit-version: "^10.5"
+ - php-version: "8.0"
+ phpunit-version: "^10.5"
+ - php-version: "7.4"
+ phpunit-version: "^11.5"
+ - php-version: "8.0"
+ phpunit-version: "^11.5"
+ - php-version: "8.1"
+ phpunit-version: "^11.5"
+ - php-version: "7.4"
+ phpunit-version: "^12.0.9"
+ - php-version: "8.0"
+ phpunit-version: "^12.0.9"
+ - php-version: "8.1"
+ phpunit-version: "^12.0.9"
+ - php-version: "8.2"
+ phpunit-version: "^12.0.9"
+
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v6
+
+ - name: "Install PHP"
+ uses: "shivammathur/setup-php@v2"
+ with:
+ coverage: "none"
+ php-version: "${{ matrix.php-version }}"
+ extensions: mbstring
+ tools: composer:v2
+
+ - name: "Require specific PHPUnit version"
+ run: "composer require --dev phpunit/phpunit:${{ matrix.phpunit-version }}"
+
+ - name: "Install lowest dependencies"
+ if: ${{ matrix.dependencies == 'lowest' }}
+ run: "composer update --prefer-lowest --no-interaction --no-progress"
+
+ - name: "Install highest dependencies"
+ if: ${{ matrix.dependencies == 'highest' }}
+ run: "composer update --no-interaction --no-progress"
+
+ - name: "PHPStan"
+ run: "make phpstan"
+
+ mutation-testing:
+ name: "Mutation Testing"
+ runs-on: "ubuntu-latest"
+ needs: ["tests", "static-analysis"]
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php-version:
+ - "8.2"
+ - "8.3"
+ - "8.4"
+ - "8.5"
+ operating-system: [ubuntu-latest]
+
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v6
+
+ - name: "Checkout build-infection"
+ uses: actions/checkout@v6
+ with:
+ repository: "phpstan/build-infection"
+ path: "build-infection"
+ ref: "1.x"
+
+ - uses: ./build-infection/.github/actions/setup-php
+ with:
+ php-version: "${{ matrix.php-version }}"
+ php-extensions: mbstring
+
+ - name: "Install dependencies"
+ run: "composer install --no-interaction --no-progress"
+
+ - name: "Install build-infection dependencies"
+ working-directory: "build-infection"
+ run: "composer install --no-interaction --no-progress"
+
+ - name: "Configure infection"
+ run: |
+ php build-infection/bin/infection-config.php \
+ > infection.json5
+ cat infection.json5 | jq
+
+ - name: "Determine default branch"
+ id: default-branch
+ run: |
+ echo "name=$(git remote show origin | sed -n '/HEAD branch/s/.*: //p')" >> $GITHUB_OUTPUT
+
+ - name: "Restore result cache"
+ uses: actions/cache/restore@v5
+ with:
+ path: ./tmp
+ key: "result-cache-v1-${{ matrix.php-version }}-${{ github.run_id }}"
+ restore-keys: |
+ result-cache-v1-${{ matrix.php-version }}-
+
+ - name: "Run infection"
+ run: |
+ git fetch --depth=1 origin ${{ steps.default-branch.outputs.name }}
+ infection \
+ --git-diff-base=origin/${{ steps.default-branch.outputs.name }} \
+ --git-diff-lines \
+ --ignore-msi-with-no-mutations \
+ --min-msi=100 \
+ --min-covered-msi=100 \
+ --log-verbosity=all \
+ --debug \
+ --logger-text=php://stdout
+
+ - name: "Save result cache"
+ uses: actions/cache/save@v5
+ if: ${{ !cancelled() }}
+ with:
+ path: ./tmp
+ key: "result-cache-v1-${{ matrix.php-version }}-${{ github.run_id }}"
diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml
new file mode 100644
index 00000000..a946f1c7
--- /dev/null
+++ b/.github/workflows/create-tag.yml
@@ -0,0 +1,53 @@
+# https://help.github.com/en/categories/automating-your-workflow-with-github-actions
+
+name: "Create tag"
+
+on:
+ # https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflow_dispatch
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Next version'
+ required: true
+ default: 'patch'
+ type: choice
+ options:
+ - patch
+ - minor
+
+jobs:
+ create-tag:
+ name: "Create tag"
+ runs-on: "ubuntu-latest"
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.PHPSTAN_BOT_TOKEN }}
+
+ - name: 'Get Previous tag'
+ id: previoustag
+ uses: "WyriHaximus/github-action-get-previous-tag@v1"
+ env:
+ GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
+
+ - name: 'Get next versions'
+ id: semvers
+ uses: "WyriHaximus/github-action-next-semvers@v1"
+ with:
+ version: ${{ steps.previoustag.outputs.tag }}
+
+ - name: "Create new minor tag"
+ uses: rickstaa/action-create-tag@v1
+ if: inputs.version == 'minor'
+ with:
+ tag: ${{ steps.semvers.outputs.minor }}
+ message: ${{ steps.semvers.outputs.minor }}
+
+ - name: "Create new patch tag"
+ uses: rickstaa/action-create-tag@v1
+ if: inputs.version == 'patch'
+ with:
+ tag: ${{ steps.semvers.outputs.patch }}
+ message: ${{ steps.semvers.outputs.patch }}
diff --git a/.github/workflows/lock-closed-issues.yml b/.github/workflows/lock-closed-issues.yml
new file mode 100644
index 00000000..e5ac0704
--- /dev/null
+++ b/.github/workflows/lock-closed-issues.yml
@@ -0,0 +1,23 @@
+name: 'Lock Issues'
+
+on:
+ schedule:
+ - cron: '7 0 * * *'
+
+jobs:
+ lock:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: dessant/lock-threads@v6
+ with:
+ github-token: ${{ github.token }}
+ issue-inactive-days: '31'
+ exclude-issue-created-before: ''
+ exclude-any-issue-labels: ''
+ add-issue-labels: ''
+ issue-comment: >
+ This thread has been automatically locked since there has not been
+ any recent activity after it was closed. Please open a new issue for
+ related bugs.
+ issue-lock-reason: 'resolved'
+ process-only: 'issues'
diff --git a/.github/workflows/release-toot.yml b/.github/workflows/release-toot.yml
new file mode 100644
index 00000000..1ba4fd77
--- /dev/null
+++ b/.github/workflows/release-toot.yml
@@ -0,0 +1,21 @@
+name: Toot release
+
+# More triggers
+# https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#release
+on:
+ release:
+ types: [published]
+
+jobs:
+ toot:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: cbrgm/mastodon-github-action@v2
+ if: ${{ !github.event.repository.private }}
+ with:
+ # GitHub event payload
+ # https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#release
+ message: "New release: ${{ github.event.repository.name }} ${{ github.event.release.tag_name }} ${{ github.event.release.html_url }} #phpstan"
+ env:
+ MASTODON_URL: https://phpc.social
+ MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }}
diff --git a/.github/workflows/release-tweet.yml b/.github/workflows/release-tweet.yml
new file mode 100644
index 00000000..d81f34ca
--- /dev/null
+++ b/.github/workflows/release-tweet.yml
@@ -0,0 +1,24 @@
+name: Tweet release
+
+# More triggers
+# https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#release
+on:
+ release:
+ types: [published]
+
+jobs:
+ tweet:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: Eomm/why-don-t-you-tweet@v2
+ if: ${{ !github.event.repository.private }}
+ with:
+ # GitHub event payload
+ # https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#release
+ tweet-message: "New release: ${{ github.event.repository.name }} ${{ github.event.release.tag_name }} ${{ github.event.release.html_url }} #phpstan"
+ env:
+ # Get your tokens from https://developer.twitter.com/apps
+ TWITTER_CONSUMER_API_KEY: ${{ secrets.TWITTER_CONSUMER_API_KEY }}
+ TWITTER_CONSUMER_API_SECRET: ${{ secrets.TWITTER_CONSUMER_API_SECRET }}
+ TWITTER_ACCESS_TOKEN: ${{ secrets.TWITTER_ACCESS_TOKEN }}
+ TWITTER_ACCESS_TOKEN_SECRET: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..efb9bbeb
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,33 @@
+# https://help.github.com/en/categories/automating-your-workflow-with-github-actions
+
+name: "Create release"
+
+on:
+ push:
+ tags:
+ - '*'
+
+jobs:
+ deploy:
+ name: "Deploy"
+ runs-on: "ubuntu-latest"
+
+ steps:
+ - name: "Checkout"
+ uses: actions/checkout@v6
+
+ - name: Generate changelog
+ id: changelog
+ uses: metcalfc/changelog-generator@v4.6.2
+ with:
+ myToken: ${{ secrets.PHPSTAN_BOT_TOKEN }}
+
+ - name: "Create release"
+ id: create-release
+ uses: actions/create-release@v1
+ env:
+ GITHUB_TOKEN: ${{ secrets.PHPSTAN_BOT_TOKEN }}
+ with:
+ tag_name: ${{ github.ref }}
+ release_name: ${{ github.ref }}
+ body: ${{ steps.changelog.outputs.changelog }}
diff --git a/.gitignore b/.gitignore
index ff72e2d0..7de9f3c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,5 @@
-/composer.lock
+/tests/tmp
+/build-cs
/vendor
+/composer.lock
+.phpunit.result.cache
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 4595318a..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-language: php
-php:
- - 7.0
- - 7.1
- - 7.2
-before_script:
- - composer self-update
- - composer install
-script:
- - vendor/bin/phing
-after_script:
- - php vendor/bin/coveralls -v
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..52fba1e2
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,23 @@
+MIT License
+
+Copyright (c) 2016 Ondřej Mirtes
+Copyright (c) 2025 PHPStan s.r.o.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..c58ca06e
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,33 @@
+.PHONY: check
+check: lint cs tests phpstan
+
+.PHONY: tests
+tests:
+ php vendor/bin/phpunit
+
+.PHONY: lint
+lint:
+ php vendor/bin/parallel-lint --colors \
+ src tests
+
+.PHONY: cs-install
+cs-install:
+ git clone https://github.com/phpstan/build-cs.git || true
+ git -C build-cs fetch origin && git -C build-cs reset --hard origin/2.x
+ composer install --working-dir build-cs
+
+.PHONY: cs
+cs:
+ php build-cs/vendor/bin/phpcs --standard=build-cs/phpcs.xml src tests
+
+.PHONY: cs-fix
+cs-fix:
+ php build-cs/vendor/bin/phpcbf --standard=build-cs/phpcs.xml src tests
+
+.PHONY: phpstan
+phpstan:
+ php vendor/bin/phpstan analyse -c phpstan.neon
+
+.PHONY: phpstan-generate-baseline
+phpstan-generate-baseline:
+ php vendor/bin/phpstan analyse -c phpstan.neon -b phpstan-baseline.neon
diff --git a/README.md b/README.md
index 75b6fe92..c86df268 100644
--- a/README.md
+++ b/README.md
@@ -1,37 +1,41 @@
# PHPStan PHPUnit extensions and rules
-[](https://travis-ci.org/phpstan/phpstan-phpunit)
+[](https://github.com/phpstan/phpstan-phpunit/actions)
[](https://packagist.org/packages/phpstan/phpstan-phpunit)
[](https://packagist.org/packages/phpstan/phpstan-phpunit)
-* [PHPStan](https://github.com/phpstan/phpstan)
+* [PHPStan](https://phpstan.org/)
* [PHPUnit](https://phpunit.de)
This extension provides following features:
-* `createMock()`, `getMockForAbstractClass()` and `getMockFromWsdl()` methods return an intersection type (see the [detailed explanation of intersection types](https://medium.com/@ondrejmirtes/union-types-vs-intersection-types-fd44a8eacbb)) of the mock object and the mocked class so that both methods from the mock object (like `expects`) and from the mocked class are available on the object.
+* `createMock()`, `getMockForAbstractClass()` and `getMockFromWsdl()` methods return an intersection type (see the [detailed explanation of intersection types](https://phpstan.org/blog/union-types-vs-intersection-types)) of the mock object and the mocked class so that both methods from the mock object (like `expects`) and from the mocked class are available on the object.
* `getMock()` called on `MockBuilder` is also supported.
-* Interprets `Foo|PHPUnit_Framework_MockObject_MockObject` in phpDoc so that it results in an intersection type instead of a union type.
+* Interprets `Foo|MockObject` in phpDoc so that it results in an intersection type instead of a union type.
* Defines early terminating method calls for the `PHPUnit\Framework\TestCase` class to prevent undefined variable errors.
+* Specifies types of expressions passed to various `assert` methods like `assertInstanceOf`, `assertTrue`, `assertInternalType` etc.
+* Combined with PHPStan's level 4, it points out always-true and always-false asserts like `assertTrue(true)` etc.
-It also contains this framework-specific rule (can be enabled separately):
+It also contains this strict framework-specific rules (can be enabled separately):
-* Check that both values passed to `assertSame()` method are of the same type.
+* Check that you are not using `assertSame()` with `true` as expected value. `assertTrue()` should be used instead.
+* Check that you are not using `assertSame()` with `false` as expected value. `assertFalse()` should be used instead.
+* Check that you are not using `assertSame()` with `null` as expected value. `assertNull()` should be used instead.
+* Check that you are not using `assertSame()` with `count($variable)` as second parameter. `assertCount($variable)` should be used instead.
+* Check that you are not using `assertEquals()` with same types (`assertSame()` should be used)
+* Check that you are not using `assertNotEquals()` with same types (`assertNotSame()` should be used)
## How to document mock objects in phpDocs?
-If you need to configure the mock even after you assign it to a property or return it from a method, you should add `PHPUnit_Framework_MockObject_MockObject` to the phpDoc:
+If you need to configure the mock even after you assign it to a property or return it from a method, you should add `\PHPUnit\Framework\MockObject\MockObject` to the type:
```php
-/**
- * @return Foo&PHPUnit_Framework_MockObject_MockObject
- */
-private function createFooMock()
+private function createFooMock(): Foo&\PHPUnit\Framework\MockObject\MockObject
{
return $this->createMock(Foo::class);
}
-public function testSomething()
+public function testSomething(): void
{
$fooMock = $this->createFooMock();
$fooMock->method('doFoo')->will($this->returnValue('test'));
@@ -39,45 +43,64 @@ public function testSomething()
}
```
-Please note that the correct syntax for intersection types is `Foo&PHPUnit_Framework_MockObject_MockObject`. `Foo|PHPUnit_Framework_MockObject_MockObject` is also supported, but only for ecosystem and legacy reasons.
+If you cannot use native intersection types yet, you can use PHPDoc instead.
+
+```php
+/**
+ * @return Foo&\PHPUnit\Framework\MockObject\MockObject
+ */
+private function createFooMock(): Foo
+{
+ return $this->createMock(Foo::class);
+}
+```
+
+Please note that the correct syntax for intersection types is `Foo&\PHPUnit\Framework\MockObject\MockObject`. `Foo|\PHPUnit\Framework\MockObject\MockObject` is also supported, but only for ecosystem and legacy reasons.
If the mock is fully configured and only the methods of the mocked class are supposed to be called on the value, it's fine to typehint only the mocked class:
```php
-/** @var Foo */
-private $foo;
+private Foo $foo;
-protected function setUp()
+protected function setUp(): void
{
$fooMock = $this->createMock(Foo::class);
$fooMock->method('doFoo')->will($this->returnValue('test'));
- $this->foo = $foo;
+ $this->foo = $fooMock;
}
-public function testSomething()
+public function testSomething(): void
{
$this->foo->doFoo();
// $this->foo->method() and expects() can no longer be called
}
```
-## Usage
+
+## Installation
To use this extension, require it in [Composer](https://getcomposer.org/):
-```bash
+```
composer require --dev phpstan/phpstan-phpunit
```
-And include extension.neon in your project's PHPStan config:
+If you also install [phpstan/extension-installer](https://github.com/phpstan/extension-installer) then you're all set!
+
+
+ Manual installation
+
+If you don't want to use `phpstan/extension-installer`, include extension.neon in your project's PHPStan config:
```
includes:
- - vendor/phpstan/phpstan-phpunit/extension.neon
+ - vendor/phpstan/phpstan-phpunit/extension.neon
```
To perform framework-specific checks, include also this file:
```
- - vendor/phpstan/phpstan-phpunit/rules.neon
+ - vendor/phpstan/phpstan-phpunit/rules.neon
```
+
+
diff --git a/build.xml b/build.xml
deleted file mode 100644
index 8fc91d1d..00000000
--- a/build.xml
+++ /dev/null
@@ -1,99 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/composer.json b/composer.json
index 32ec0a92..39d7a030 100644
--- a/composer.json
+++ b/composer.json
@@ -1,26 +1,34 @@
{
"name": "phpstan/phpstan-phpunit",
+ "type": "phpstan-extension",
"description": "PHPUnit extensions and rules for PHPStan",
- "license": ["MIT"],
- "minimum-stability": "dev",
- "prefer-stable": true,
- "extra": {
- "branch-alias": {
- "dev-master": "0.9-dev"
- }
- },
+ "license": [
+ "MIT"
+ ],
"require": {
- "php": "~7.0",
- "phpstan/phpstan": "^0.9.1",
- "phpunit/phpunit": "^6.3"
+ "php": "^7.4 || ^8.0",
+ "phpstan/phpstan": "^2.1.32"
+ },
+ "conflict": {
+ "phpunit/phpunit": "<7.0"
},
"require-dev": {
- "consistence/coding-standard": "^2.0",
- "jakub-onderka/php-parallel-lint": "^0.9.2",
- "phing/phing": "^2.16.0",
- "phpstan/phpstan-strict-rules": "^0.9",
- "satooshi/php-coveralls": "^1.0",
- "slevomat/coding-standard": "^3.3.0"
+ "nikic/php-parser": "^5",
+ "php-parallel-lint/php-parallel-lint": "^1.2",
+ "phpstan/phpstan-deprecation-rules": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/phpunit": "^9.6"
+ },
+ "config": {
+ "sort-packages": true
+ },
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon",
+ "rules.neon"
+ ]
+ }
},
"autoload": {
"psr-4": {
@@ -28,6 +36,10 @@
}
},
"autoload-dev": {
- "classmap": ["tests/"]
- }
+ "classmap": [
+ "tests/"
+ ]
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true
}
diff --git a/extension.neon b/extension.neon
index f3a1e35c..4b2d9c54 100644
--- a/extension.neon
+++ b/extension.neon
@@ -1,20 +1,79 @@
parameters:
+ phpunit:
+ convertUnionToIntersectionType: true
+ reportMissingDataProviderReturnType: false
+ additionalConstructors:
+ - PHPUnit\Framework\TestCase::setUp
earlyTerminatingMethodCalls:
- PHPUnit\Framework\TestCase:
+ PHPUnit\Framework\Assert:
- fail
- markTestIncomplete
- markTestSkipped
+ stubFiles:
+ - stubs/Assert.stub
+ - stubs/AssertionFailedError.stub
+ - stubs/ExpectationFailedException.stub
+ - stubs/MockBuilder.stub
+ - stubs/MockObject.stub
+ - stubs/Stub.stub
+ - stubs/TestCase.stub
+ exceptions:
+ uncheckedExceptionRegexes:
+ - '#^PHPUnit\\#'
+ - '#^SebastianBergmann\\#'
+
+parametersSchema:
+ phpunit: structure([
+ convertUnionToIntersectionType: bool(),
+ reportMissingDataProviderReturnType: bool(),
+ ])
services:
-
- class: PHPStan\Type\PHPUnit\CreateMockDynamicReturnTypeExtension
+ class: PHPStan\PhpDoc\PHPUnit\MockObjectTypeNodeResolverExtension
+ -
+ class: PHPStan\Type\PHPUnit\Assert\AssertFunctionTypeSpecifyingExtension
tags:
- - phpstan.broker.dynamicMethodReturnTypeExtension
+ - phpstan.typeSpecifier.functionTypeSpecifyingExtension
-
- class: PHPStan\Type\PHPUnit\GetMockBuilderDynamicReturnTypeExtension
+ class: PHPStan\Type\PHPUnit\Assert\AssertMethodTypeSpecifyingExtension
tags:
- - phpstan.broker.dynamicMethodReturnTypeExtension
+ - phpstan.typeSpecifier.methodTypeSpecifyingExtension
+ -
+ class: PHPStan\Type\PHPUnit\Assert\AssertStaticMethodTypeSpecifyingExtension
+ tags:
+ - phpstan.typeSpecifier.staticMethodTypeSpecifyingExtension
-
class: PHPStan\Type\PHPUnit\MockBuilderDynamicReturnTypeExtension
tags:
- phpstan.broker.dynamicMethodReturnTypeExtension
+ -
+ class: PHPStan\Rules\PHPUnit\CoversHelper
+ -
+ class: PHPStan\Rules\PHPUnit\AnnotationHelper
+
+ -
+ class: PHPStan\Rules\PHPUnit\TestMethodsHelper
+
+ -
+ class: PHPStan\Rules\PHPUnit\PHPUnitVersion
+ factory: @PHPStan\Rules\PHPUnit\PHPUnitVersionDetector::createPHPUnitVersion()
+ -
+ class: PHPStan\Rules\PHPUnit\PHPUnitVersionDetector
+
+ -
+ class: PHPStan\Rules\PHPUnit\DataProviderHelper
+ factory: @PHPStan\Rules\PHPUnit\DataProviderHelperFactory::create()
+ -
+ class: PHPStan\Rules\PHPUnit\DataProviderHelperFactory
+ arguments:
+ parser: @defaultAnalysisParser
+
+ -
+ class: PHPStan\Type\PHPUnit\DataProviderReturnTypeIgnoreExtension
+
+conditionalTags:
+ PHPStan\PhpDoc\PHPUnit\MockObjectTypeNodeResolverExtension:
+ phpstan.phpDoc.typeNodeResolverExtension: %phpunit.convertUnionToIntersectionType%
+ PHPStan\Type\PHPUnit\DataProviderReturnTypeIgnoreExtension:
+ phpstan.ignoreErrorExtension: [%featureToggles.bleedingEdge%, not(%phpunit.reportMissingDataProviderReturnType%)]
diff --git a/phpcs.xml b/phpcs.xml
deleted file mode 100644
index 28ee4e94..00000000
--- a/phpcs.xml
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
new file mode 100644
index 00000000..f56b3cfb
--- /dev/null
+++ b/phpstan-baseline.neon
@@ -0,0 +1,21 @@
+parameters:
+ ignoreErrors:
+ -
+ message: "#^Accessing PHPStan\\\\Rules\\\\Comparison\\\\ImpossibleCheckTypeMethodCallRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#"
+ count: 1
+ path: tests/Rules/PHPUnit/AssertSameMethodDifferentTypesRuleTest.php
+
+ -
+ message: "#^Accessing PHPStan\\\\Rules\\\\Comparison\\\\ImpossibleCheckTypeStaticMethodCallRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#"
+ count: 1
+ path: tests/Rules/PHPUnit/AssertSameStaticMethodDifferentTypesRuleTest.php
+
+ -
+ message: "#^Accessing PHPStan\\\\Rules\\\\Comparison\\\\ImpossibleCheckTypeMethodCallRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#"
+ count: 1
+ path: tests/Rules/PHPUnit/ImpossibleCheckTypeMethodCallRuleTest.php
+
+ -
+ message: "#^Accessing PHPStan\\\\Rules\\\\Methods\\\\CallMethodsRule\\:\\:class is not covered by backward compatibility promise\\. The class might change in a minor PHPStan version\\.$#"
+ count: 1
+ path: tests/Rules/Methods/CallMethodsRuleTest.php
diff --git a/phpstan.neon b/phpstan.neon
index 5ec89139..7b35ce80 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -2,3 +2,24 @@ includes:
- extension.neon
- rules.neon
- vendor/phpstan/phpstan-strict-rules/rules.neon
+ - vendor/phpstan/phpstan-deprecation-rules/rules.neon
+ - phar://phpstan.phar/conf/bleedingEdge.neon
+ - phpstan-baseline.neon
+
+parameters:
+ level: 8
+ reportUnmatchedIgnoredErrors: false
+
+ resultCachePath: tmp/resultCache.php
+
+ paths:
+ - src
+ - tests
+
+ excludePaths:
+ - tests/*/data/*
+ ignoreErrors:
+ -
+ message: '#^Attribute class PHPUnit\\Framework\\Attributes\\DataProvider does not exist\.$#'
+ identifier: attribute.notFound
+ reportUnmatched: false
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 00000000..2ccef9fc
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ tests
+
+
+
+
+
diff --git a/rules.neon b/rules.neon
index 161e3e85..7bff0161 100644
--- a/rules.neon
+++ b/rules.neon
@@ -1,5 +1,39 @@
+rules:
+ - PHPStan\Rules\PHPUnit\AssertSameBooleanExpectedRule
+ - PHPStan\Rules\PHPUnit\AssertSameNullExpectedRule
+ - PHPStan\Rules\PHPUnit\AssertSameWithCountRule
+ - PHPStan\Rules\PHPUnit\ClassCoversExistsRule
+ - PHPStan\Rules\PHPUnit\ClassMethodCoversExistsRule
+ - PHPStan\Rules\PHPUnit\MockMethodCallRule
+ - PHPStan\Rules\PHPUnit\NoMissingSpaceInClassAnnotationRule
+ - PHPStan\Rules\PHPUnit\NoMissingSpaceInMethodAnnotationRule
+ - PHPStan\Rules\PHPUnit\ShouldCallParentMethodsRule
+
+conditionalTags:
+ PHPStan\Rules\PHPUnit\AssertEqualsIsDiscouragedRule:
+ phpstan.rules.rule: [%strictRulesInstalled%, %featureToggles.bleedingEdge%]
+
+ PHPStan\Rules\PHPUnit\DataProviderDataRule:
+ phpstan.rules.rule: %featureToggles.bleedingEdge%
+
services:
-
- class: PHPStan\Rules\PHPUnit\AssertSameDifferentTypesRule
+ class: PHPStan\Rules\PHPUnit\DataProviderDeclarationRule
+ arguments:
+ checkFunctionNameCase: %checkFunctionNameCase%
+ deprecationRulesInstalled: %deprecationRulesInstalled%
tags:
- phpstan.rules.rule
+
+ -
+ class: PHPStan\Rules\PHPUnit\AttributeRequiresPhpVersionRule
+ arguments:
+ deprecationRulesInstalled: %deprecationRulesInstalled%
+ tags:
+ - phpstan.rules.rule
+
+ -
+ class: PHPStan\Rules\PHPUnit\AssertEqualsIsDiscouragedRule
+
+ -
+ class: PHPStan\Rules\PHPUnit\DataProviderDataRule
diff --git a/src/PhpDoc/PHPUnit/MockObjectTypeNodeResolverExtension.php b/src/PhpDoc/PHPUnit/MockObjectTypeNodeResolverExtension.php
new file mode 100644
index 00000000..83f7b8b2
--- /dev/null
+++ b/src/PhpDoc/PHPUnit/MockObjectTypeNodeResolverExtension.php
@@ -0,0 +1,64 @@
+typeNodeResolver = $typeNodeResolver;
+ }
+
+ public function getCacheKey(): string
+ {
+ return 'phpunit-v1';
+ }
+
+ public function resolve(TypeNode $typeNode, NameScope $nameScope): ?Type
+ {
+ if (!$typeNode instanceof UnionTypeNode) {
+ return null;
+ }
+
+ static $mockClassNames = [
+ 'PHPUnit_Framework_MockObject_MockObject' => true,
+ 'PHPUnit\Framework\MockObject\MockObject' => true,
+ 'PHPUnit\Framework\MockObject\Stub' => true,
+ ];
+
+ $types = $this->typeNodeResolver->resolveMultiple($typeNode->types, $nameScope);
+ foreach ($types as $type) {
+ $classNames = $type->getObjectClassNames();
+ if (count($classNames) !== 1) {
+ continue;
+ }
+
+ if (array_key_exists($classNames[0], $mockClassNames)) {
+ $resultType = TypeCombinator::intersect(...$types);
+ if ($resultType instanceof NeverType) {
+ continue;
+ }
+
+ return $resultType;
+ }
+ }
+
+ return null;
+ }
+
+}
diff --git a/src/Rules/PHPUnit/AnnotationHelper.php b/src/Rules/PHPUnit/AnnotationHelper.php
new file mode 100644
index 00000000..21623cab
--- /dev/null
+++ b/src/Rules/PHPUnit/AnnotationHelper.php
@@ -0,0 +1,66 @@
+ errors
+ */
+ public function processDocComment(Doc $docComment): array
+ {
+ $errors = [];
+ $docCommentLines = preg_split("/((\r?\n)|(\r\n?))/", $docComment->getText());
+ if ($docCommentLines === false) {
+ return [];
+ }
+
+ foreach ($docCommentLines as $docCommentLine) {
+ // These annotations can't be retrieved using the getResolvedPhpDoc method on the FileTypeMapper as they are not present when they are invalid
+ $annotation = preg_match('/(?@(?[a-zA-Z]+)(?\s*)(?.*))/', $docCommentLine, $matches);
+ if ($annotation === false) {
+ continue; // Line without annotation
+ }
+
+ if (array_key_exists('property', $matches) === false || array_key_exists('whitespace', $matches) === false || array_key_exists('annotation', $matches) === false) {
+ continue;
+ }
+
+ if (!in_array($matches['property'], self::ANNOTATIONS_WITH_PARAMS, true) || $matches['whitespace'] !== '') {
+ continue;
+ }
+
+ $errors[] = RuleErrorBuilder::message(
+ 'Annotation "' . $matches['annotation'] . '" is invalid, "@' . $matches['property'] . '" should be followed by a space and a value.',
+ )->identifier('phpunit.invalidPhpDoc')->build();
+ }
+
+ return $errors;
+ }
+
+}
diff --git a/src/Rules/PHPUnit/AssertEqualsIsDiscouragedRule.php b/src/Rules/PHPUnit/AssertEqualsIsDiscouragedRule.php
new file mode 100644
index 00000000..ed6470e2
--- /dev/null
+++ b/src/Rules/PHPUnit/AssertEqualsIsDiscouragedRule.php
@@ -0,0 +1,87 @@
+
+ */
+class AssertEqualsIsDiscouragedRule implements Rule
+{
+
+ public function getNodeType(): string
+ {
+ return CallLike::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ if (!$node instanceof Node\Expr\MethodCall && ! $node instanceof Node\Expr\StaticCall) {
+ return [];
+ }
+ if (count($node->getArgs()) < 2) {
+ return [];
+ }
+ if ($node->isFirstClassCallable()) {
+ return [];
+ }
+
+ if (
+ !$node->name instanceof Node\Identifier
+ || !in_array(strtolower($node->name->name), ['assertequals', 'assertnotequals'], true)
+ ) {
+ return [];
+ }
+
+ if (!AssertRuleHelper::isMethodOrStaticCallOnAssert($node, $scope)) {
+ return [];
+ }
+
+ $leftType = TypeCombinator::removeNull($scope->getType($node->getArgs()[0]->value));
+ $rightType = TypeCombinator::removeNull($scope->getType($node->getArgs()[1]->value));
+
+ if ($leftType->isConstantScalarValue()->yes()) {
+ $leftType = $leftType->generalize(GeneralizePrecision::lessSpecific());
+ }
+ if ($rightType->isConstantScalarValue()->yes()) {
+ $rightType = $rightType->generalize(GeneralizePrecision::lessSpecific());
+ }
+
+ if (
+ ($leftType->isScalar()->yes() && $rightType->isScalar()->yes())
+ && ($leftType->isSuperTypeOf($rightType)->yes())
+ && ($rightType->isSuperTypeOf($leftType)->yes())
+ ) {
+ $correctName = strtolower($node->name->name) === 'assertnotequals' ? 'assertNotSame' : 'assertSame';
+ return [
+ RuleErrorBuilder::message(
+ sprintf(
+ 'You should use %s() instead of %s(), because both values are scalars of the same type',
+ $correctName,
+ $node->name->name,
+ ),
+ )->identifier('phpunit.assertEquals')
+ ->fixNode($node, static function (CallLike $node) use ($correctName) {
+ $node->name = new Node\Identifier($correctName);
+
+ return $node;
+ })
+ ->build(),
+ ];
+ }
+
+ return [];
+ }
+
+}
diff --git a/src/Rules/PHPUnit/AssertRuleHelper.php b/src/Rules/PHPUnit/AssertRuleHelper.php
new file mode 100644
index 00000000..ecaec91d
--- /dev/null
+++ b/src/Rules/PHPUnit/AssertRuleHelper.php
@@ -0,0 +1,49 @@
+getType($node->var);
+ } elseif ($node instanceof Node\Expr\StaticCall) {
+ if ($node->class instanceof Node\Name) {
+ $class = (string) $node->class;
+ if (
+ $scope->isInClass()
+ && in_array(
+ strtolower($class),
+ [
+ 'self',
+ 'static',
+ 'parent',
+ ],
+ true,
+ )
+ ) {
+ $calledOnType = new ObjectType($scope->getClassReflection()->getName());
+ } else {
+ $calledOnType = new ObjectType($class);
+ }
+ } else {
+ $calledOnType = $scope->getType($node->class);
+ }
+ } else {
+ return false;
+ }
+
+ $testCaseType = new ObjectType('PHPUnit\Framework\Assert');
+
+ return $testCaseType->isSuperTypeOf($calledOnType)->yes();
+ }
+
+}
diff --git a/src/Rules/PHPUnit/AssertSameBooleanExpectedRule.php b/src/Rules/PHPUnit/AssertSameBooleanExpectedRule.php
new file mode 100644
index 00000000..f185bdf7
--- /dev/null
+++ b/src/Rules/PHPUnit/AssertSameBooleanExpectedRule.php
@@ -0,0 +1,92 @@
+
+ */
+class AssertSameBooleanExpectedRule implements Rule
+{
+
+ public function getNodeType(): string
+ {
+ return CallLike::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ if (!$node instanceof Node\Expr\MethodCall && ! $node instanceof Node\Expr\StaticCall) {
+ return [];
+ }
+ if (count($node->getArgs()) < 2) {
+ return [];
+ }
+ if ($node->isFirstClassCallable()) {
+ return [];
+ }
+ if (!$node->name instanceof Node\Identifier || $node->name->toLowerString() !== 'assertsame') {
+ return [];
+ }
+
+ $expectedArgumentValue = $node->getArgs()[0]->value;
+ if (!($expectedArgumentValue instanceof ConstFetch)) {
+ return [];
+ }
+
+ if (!AssertRuleHelper::isMethodOrStaticCallOnAssert($node, $scope)) {
+ return [];
+ }
+
+ if ($expectedArgumentValue->name->toLowerString() === 'true') {
+ return [
+ RuleErrorBuilder::message('You should use assertTrue() instead of assertSame() when expecting "true"')
+ ->identifier('phpunit.assertTrue')
+ ->fixNode($node, static function (CallLike $node) {
+ $node->name = new Node\Identifier('assertTrue');
+ $node->args = self::rewriteArgs($node->args);
+
+ return $node;
+ })
+ ->build(),
+ ];
+ }
+
+ if ($expectedArgumentValue->name->toLowerString() === 'false') {
+ return [
+ RuleErrorBuilder::message('You should use assertFalse() instead of assertSame() when expecting "false"')
+ ->identifier('phpunit.assertFalse')
+ ->fixNode($node, static function (CallLike $node) {
+ $node->name = new Node\Identifier('assertFalse');
+ $node->args = self::rewriteArgs($node->args);
+
+ return $node;
+ })
+ ->build(),
+ ];
+ }
+
+ return [];
+ }
+
+ /**
+ * @param array $args
+ * @return list
+ */
+ private static function rewriteArgs(array $args): array
+ {
+ $newArgs = [];
+ for ($i = 1; $i < count($args); $i++) {
+ $newArgs[] = $args[$i];
+ }
+ return $newArgs;
+ }
+
+}
diff --git a/src/Rules/PHPUnit/AssertSameDifferentTypesRule.php b/src/Rules/PHPUnit/AssertSameDifferentTypesRule.php
deleted file mode 100644
index c16c6bcd..00000000
--- a/src/Rules/PHPUnit/AssertSameDifferentTypesRule.php
+++ /dev/null
@@ -1,77 +0,0 @@
-getType($node->var);
- } elseif ($node instanceof Node\Expr\StaticCall) {
- if ($node->class instanceof Node\Name) {
- $class = (string) $node->class;
- if (in_array(
- strtolower($class),
- [
- 'self',
- 'static',
- 'parent',
- ],
- true
- )) {
- $calledOnType = new ObjectType($scope->getClassReflection()->getName());
- } else {
- $calledOnType = new ObjectType($class);
- }
- } else {
- $calledOnType = $scope->getType($node->class);
- }
- } else {
- return [];
- }
-
- if (!$testCaseType->isSuperTypeOf($calledOnType)->yes()) {
- return [];
- }
-
- if (count($node->args) < 2) {
- return [];
- }
- if (!is_string($node->name) || strtolower($node->name) !== 'assertsame') {
- return [];
- }
-
- $leftType = $scope->getType($node->args[0]->value);
- $rightType = $scope->getType($node->args[1]->value);
-
- if ($leftType->isSuperTypeOf($rightType)->no()) {
- return [
- sprintf(
- 'Call to assertSame() with different types %s and %s will always result in test failure.',
- $leftType->describe(),
- $rightType->describe()
- ),
- ];
- }
-
- return [];
- }
-
-}
diff --git a/src/Rules/PHPUnit/AssertSameNullExpectedRule.php b/src/Rules/PHPUnit/AssertSameNullExpectedRule.php
new file mode 100644
index 00000000..f6032efb
--- /dev/null
+++ b/src/Rules/PHPUnit/AssertSameNullExpectedRule.php
@@ -0,0 +1,78 @@
+
+ */
+class AssertSameNullExpectedRule implements Rule
+{
+
+ public function getNodeType(): string
+ {
+ return CallLike::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ if (!$node instanceof Node\Expr\MethodCall && ! $node instanceof Node\Expr\StaticCall) {
+ return [];
+ }
+ if (count($node->getArgs()) < 2) {
+ return [];
+ }
+ if ($node->isFirstClassCallable()) {
+ return [];
+ }
+ if (!$node->name instanceof Node\Identifier || $node->name->toLowerString() !== 'assertsame') {
+ return [];
+ }
+
+ if (!AssertRuleHelper::isMethodOrStaticCallOnAssert($node, $scope)) {
+ return [];
+ }
+
+ $expectedArgumentValue = $node->getArgs()[0]->value;
+ if (!($expectedArgumentValue instanceof ConstFetch)) {
+ return [];
+ }
+
+ if ($expectedArgumentValue->name->toLowerString() === 'null') {
+ return [
+ RuleErrorBuilder::message('You should use assertNull() instead of assertSame(null, $actual).')
+ ->identifier('phpunit.assertNull')
+ ->fixNode($node, static function (CallLike $node) {
+ $node->name = new Node\Identifier('assertNull');
+ $node->args = self::rewriteArgs($node->args);
+
+ return $node;
+ })
+ ->build(),
+ ];
+ }
+
+ return [];
+ }
+
+ /**
+ * @param array $args
+ * @return list
+ */
+ private static function rewriteArgs(array $args): array
+ {
+ $newArgs = [];
+ for ($i = 1; $i < count($args); $i++) {
+ $newArgs[] = $args[$i];
+ }
+ return $newArgs;
+ }
+
+}
diff --git a/src/Rules/PHPUnit/AssertSameWithCountRule.php b/src/Rules/PHPUnit/AssertSameWithCountRule.php
new file mode 100644
index 00000000..2a5a7651
--- /dev/null
+++ b/src/Rules/PHPUnit/AssertSameWithCountRule.php
@@ -0,0 +1,112 @@
+
+ */
+class AssertSameWithCountRule implements Rule
+{
+
+ public function getNodeType(): string
+ {
+ return CallLike::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ if (!$node instanceof Node\Expr\MethodCall && ! $node instanceof Node\Expr\StaticCall) {
+ return [];
+ }
+ if (count($node->getArgs()) < 2) {
+ return [];
+ }
+ if ($node->isFirstClassCallable()) {
+ return [];
+ }
+ if (!$node->name instanceof Node\Identifier || $node->name->toLowerString() !== 'assertsame') {
+ return [];
+ }
+
+ if (!AssertRuleHelper::isMethodOrStaticCallOnAssert($node, $scope)) {
+ return [];
+ }
+
+ $right = $node->getArgs()[1]->value;
+ if (self::isCountFunctionCall($right, $scope)) {
+ return [
+ RuleErrorBuilder::message('You should use assertCount($expectedCount, $variable) instead of assertSame($expectedCount, count($variable)).')
+ ->identifier('phpunit.assertCount')
+ ->build(),
+ ];
+ }
+
+ if (self::isCountableMethodCall($right, $scope)) {
+ return [
+ RuleErrorBuilder::message('You should use assertCount($expectedCount, $variable) instead of assertSame($expectedCount, $variable->count()).')
+ ->identifier('phpunit.assertCount')
+ ->build(),
+ ];
+ }
+
+ return [];
+ }
+
+ /**
+ * @phpstan-assert-if-true Node\Expr\FuncCall $expr
+ */
+ private static function isCountFunctionCall(Node\Expr $expr, Scope $scope): bool
+ {
+ return $expr instanceof Node\Expr\FuncCall
+ && $expr->name instanceof Node\Name
+ && $expr->name->toLowerString() === 'count'
+ && count($expr->getArgs()) >= 1
+ && self::isNormalCount($expr, $scope->getType($expr->getArgs()[0]->value), $scope)->yes();
+ }
+
+ /**
+ * @phpstan-assert-if-true Node\Expr\MethodCall $expr
+ */
+ private static function isCountableMethodCall(Node\Expr $expr, Scope $scope): bool
+ {
+ if (
+ $expr instanceof Node\Expr\MethodCall
+ && $expr->name instanceof Node\Identifier
+ && $expr->name->toLowerString() === 'count'
+ && count($expr->getArgs()) === 0
+ ) {
+ $type = $scope->getType($expr->var);
+
+ if ((new ObjectType(Countable::class))->isSuperTypeOf($type)->yes()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static function isNormalCount(Node\Expr\FuncCall $countFuncCall, Type $countedType, Scope $scope): TrinaryLogic
+ {
+ if (count($countFuncCall->getArgs()) === 1) {
+ $isNormalCount = TrinaryLogic::createYes();
+ } else {
+ $mode = $scope->getType($countFuncCall->getArgs()[1]->value);
+ $isNormalCount = (new ConstantIntegerType(COUNT_NORMAL))->isSuperTypeOf($mode)->result->or($countedType->getIterableValueType()->isArray()->negate());
+ }
+ return $isNormalCount;
+ }
+
+}
diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php
new file mode 100644
index 00000000..f106bf8a
--- /dev/null
+++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php
@@ -0,0 +1,99 @@
+
+ */
+class AttributeRequiresPhpVersionRule implements Rule
+{
+
+ private PHPUnitVersion $PHPUnitVersion;
+
+ private TestMethodsHelper $testMethodsHelper;
+
+ /**
+ * When phpstan-deprecation-rules is installed, it reports deprecated usages.
+ */
+ private bool $deprecationRulesInstalled;
+
+ public function __construct(
+ PHPUnitVersion $PHPUnitVersion,
+ TestMethodsHelper $testMethodsHelper,
+ bool $deprecationRulesInstalled
+ )
+ {
+ $this->PHPUnitVersion = $PHPUnitVersion;
+ $this->testMethodsHelper = $testMethodsHelper;
+ $this->deprecationRulesInstalled = $deprecationRulesInstalled;
+ }
+
+ public function getNodeType(): string
+ {
+ return InClassMethodNode::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ $classReflection = $scope->getClassReflection();
+ if ($classReflection === null || $classReflection->is(TestCase::class) === false) {
+ return [];
+ }
+
+ $reflectionMethod = $this->testMethodsHelper->getTestMethodReflection($classReflection, $node->getMethodReflection(), $scope);
+ if ($reflectionMethod === null) {
+ return [];
+ }
+
+ /** @phpstan-ignore function.alreadyNarrowedType */
+ if (!method_exists($reflectionMethod, 'getAttributes')) {
+ return [];
+ }
+
+ $errors = [];
+ foreach ($reflectionMethod->getAttributes('PHPUnit\Framework\Attributes\RequiresPhp') as $attr) {
+ $args = $attr->getArguments();
+ if (count($args) !== 1) {
+ continue;
+ }
+
+ if (
+ !is_numeric($args[0])
+ ) {
+ continue;
+ }
+
+ if ($this->PHPUnitVersion->requiresPhpversionAttributeWithOperator()->yes()) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf('Version requirement is missing operator.'),
+ )
+ ->identifier('phpunit.attributeRequiresPhpVersion')
+ ->build();
+ } elseif (
+ $this->deprecationRulesInstalled
+ && $this->PHPUnitVersion->deprecatesPhpversionAttributeWithoutOperator()->yes()
+ ) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf('Version requirement without operator is deprecated.'),
+ )
+ ->identifier('phpunit.attributeRequiresPhpVersion')
+ ->build();
+ }
+
+ }
+
+ return $errors;
+ }
+
+}
diff --git a/src/Rules/PHPUnit/ClassCoversExistsRule.php b/src/Rules/PHPUnit/ClassCoversExistsRule.php
new file mode 100644
index 00000000..a36317ef
--- /dev/null
+++ b/src/Rules/PHPUnit/ClassCoversExistsRule.php
@@ -0,0 +1,91 @@
+
+ */
+class ClassCoversExistsRule implements Rule
+{
+
+ /**
+ * Covers helper.
+ *
+ */
+ private CoversHelper $coversHelper;
+
+ /**
+ * Reflection provider.
+ *
+ */
+ private ReflectionProvider $reflectionProvider;
+
+ public function __construct(
+ CoversHelper $coversHelper,
+ ReflectionProvider $reflectionProvider
+ )
+ {
+ $this->reflectionProvider = $reflectionProvider;
+ $this->coversHelper = $coversHelper;
+ }
+
+ public function getNodeType(): string
+ {
+ return InClassNode::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ $classReflection = $node->getClassReflection();
+
+ if (!$classReflection->is(TestCase::class)) {
+ return [];
+ }
+
+ $classPhpDoc = $classReflection->getResolvedPhpDoc();
+ [$classCovers, $classCoversDefaultClasses] = $this->coversHelper->getCoverAnnotations($classPhpDoc);
+
+ if (count($classCoversDefaultClasses) >= 2) {
+ return [
+ RuleErrorBuilder::message(sprintf(
+ '@coversDefaultClass is defined multiple times.',
+ ))->identifier('phpunit.coversDuplicate')->build(),
+ ];
+ }
+
+ $errors = [];
+ $coversDefaultClass = array_shift($classCoversDefaultClasses);
+
+ if ($coversDefaultClass !== null) {
+ $className = (string) $coversDefaultClass->value;
+ if (!$this->reflectionProvider->hasClass($className)) {
+ $errors[] = RuleErrorBuilder::message(sprintf(
+ '@coversDefaultClass references an invalid class %s.',
+ $className,
+ ))->identifier('phpunit.coversClass')->build();
+ }
+ }
+
+ foreach ($classCovers as $covers) {
+ $errors = array_merge(
+ $errors,
+ $this->coversHelper->processCovers($node, $covers, null),
+ );
+ }
+
+ return $errors;
+ }
+
+}
diff --git a/src/Rules/PHPUnit/ClassMethodCoversExistsRule.php b/src/Rules/PHPUnit/ClassMethodCoversExistsRule.php
new file mode 100644
index 00000000..dd328f83
--- /dev/null
+++ b/src/Rules/PHPUnit/ClassMethodCoversExistsRule.php
@@ -0,0 +1,113 @@
+
+ */
+class ClassMethodCoversExistsRule implements Rule
+{
+
+ /**
+ * Covers helper.
+ *
+ */
+ private CoversHelper $coversHelper;
+
+ /**
+ * The file type mapper.
+ *
+ */
+ private FileTypeMapper $fileTypeMapper;
+
+ public function __construct(
+ CoversHelper $coversHelper,
+ FileTypeMapper $fileTypeMapper
+ )
+ {
+ $this->coversHelper = $coversHelper;
+ $this->fileTypeMapper = $fileTypeMapper;
+ }
+
+ public function getNodeType(): string
+ {
+ return Node\Stmt\ClassMethod::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ $classReflection = $scope->getClassReflection();
+
+ if ($classReflection === null) {
+ return [];
+ }
+
+ if (!$classReflection->is(TestCase::class)) {
+ return [];
+ }
+
+ $classPhpDoc = $classReflection->getResolvedPhpDoc();
+ [$classCovers, $classCoversDefaultClasses] = $this->coversHelper->getCoverAnnotations($classPhpDoc);
+
+ $classCoversStrings = array_map(static fn (PhpDocTagNode $covers): string => (string) $covers->value, $classCovers);
+
+ $docComment = $node->getDocComment();
+ if ($docComment === null) {
+ return [];
+ }
+
+ $coversDefaultClass = count($classCoversDefaultClasses) === 1
+ ? array_shift($classCoversDefaultClasses)
+ : null;
+
+ $methodPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc(
+ $scope->getFile(),
+ $classReflection->getName(),
+ $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null,
+ $node->name->toString(),
+ $docComment->getText(),
+ );
+
+ [$methodCovers, $methodCoversDefaultClasses] = $this->coversHelper->getCoverAnnotations($methodPhpDoc);
+
+ $errors = [];
+
+ if (count($methodCoversDefaultClasses) > 0) {
+ $errors[] = RuleErrorBuilder::message(sprintf(
+ '@coversDefaultClass defined on class method %s.',
+ $node->name,
+ ))->identifier('phpunit.covers')->build();
+ }
+
+ foreach ($methodCovers as $covers) {
+ if (in_array((string) $covers->value, $classCoversStrings, true)) {
+ $errors[] = RuleErrorBuilder::message(sprintf(
+ 'Class already @covers %s so the method @covers is redundant.',
+ $covers->value,
+ ))->identifier('phpunit.coversDuplicate')->build();
+ }
+
+ $errors = array_merge(
+ $errors,
+ $this->coversHelper->processCovers($node, $covers, $coversDefaultClass),
+ );
+ }
+
+ return $errors;
+ }
+
+}
diff --git a/src/Rules/PHPUnit/CoversHelper.php b/src/Rules/PHPUnit/CoversHelper.php
new file mode 100644
index 00000000..40ae561e
--- /dev/null
+++ b/src/Rules/PHPUnit/CoversHelper.php
@@ -0,0 +1,132 @@
+reflectionProvider = $reflectionProvider;
+ }
+
+ /**
+ * Gathers @covers and @coversDefaultClass annotations from phpdocs.
+ *
+ * @return array{PhpDocTagNode[], PhpDocTagNode[]}
+ */
+ public function getCoverAnnotations(?ResolvedPhpDocBlock $phpDoc): array
+ {
+ if ($phpDoc === null) {
+ return [[], []];
+ }
+
+ $phpDocNodes = $phpDoc->getPhpDocNodes();
+
+ $covers = [];
+ $coversDefaultClasses = [];
+
+ foreach ($phpDocNodes as $docNode) {
+ $covers = array_merge(
+ $covers,
+ $docNode->getTagsByName('@covers'),
+ );
+
+ $coversDefaultClasses = array_merge(
+ $coversDefaultClasses,
+ $docNode->getTagsByName('@coversDefaultClass'),
+ );
+ }
+
+ return [$covers, $coversDefaultClasses];
+ }
+
+ /**
+ * @return list errors
+ */
+ public function processCovers(
+ Node $node,
+ PhpDocTagNode $phpDocTag,
+ ?PhpDocTagNode $coversDefaultClass
+ ): array
+ {
+ $errors = [];
+ $covers = (string) $phpDocTag->value;
+
+ if ($covers === '') {
+ $errors[] = RuleErrorBuilder::message('@covers value does not specify anything.')
+ ->identifier('phpunit.covers')
+ ->build();
+
+ return $errors;
+ }
+
+ $isMethod = strpos($covers, '::') !== false;
+ $fullName = $covers;
+
+ if ($isMethod) {
+ [$className, $method] = explode('::', $covers);
+ } else {
+ $className = $covers;
+ }
+
+ if ($className === '' && $node instanceof Node\Stmt\ClassMethod && $coversDefaultClass !== null) {
+ $className = (string) $coversDefaultClass->value;
+ $fullName = $className . $covers;
+ }
+
+ if ($this->reflectionProvider->hasClass($className)) {
+ $class = $this->reflectionProvider->getClass($className);
+
+ if ($class->isInterface()) {
+ $errors[] = RuleErrorBuilder::message(sprintf(
+ '@covers value %s references an interface.',
+ $fullName,
+ ))->identifier('phpunit.coversInterface')->build();
+ }
+
+ if (isset($method) && $method !== '' && !$class->hasMethod($method)) {
+ $errors[] = RuleErrorBuilder::message(sprintf(
+ '@covers value %s references an invalid method.',
+ $fullName,
+ ))->identifier('phpunit.coversMethod')->build();
+ }
+ } elseif (isset($method) && $this->reflectionProvider->hasFunction(new Name($method, []), null)) {
+ return $errors;
+ } elseif (!isset($method) && $this->reflectionProvider->hasFunction(new Name($className, []), null)) {
+ return $errors;
+ } else {
+ $error = RuleErrorBuilder::message(sprintf(
+ '@covers value %s references an invalid %s.',
+ $fullName,
+ $isMethod ? 'method' : 'class or function',
+ ))->identifier(sprintf('phpunit.covers%s', $isMethod ? 'Method' : ''));
+
+ if (strpos($className, '\\') === false) {
+ $error->tip('The @covers annotation requires a fully qualified name.');
+ }
+
+ $errors[] = $error->build();
+ }
+ return $errors;
+ }
+
+}
diff --git a/src/Rules/PHPUnit/DataProviderDataRule.php b/src/Rules/PHPUnit/DataProviderDataRule.php
new file mode 100644
index 00000000..ce994676
--- /dev/null
+++ b/src/Rules/PHPUnit/DataProviderDataRule.php
@@ -0,0 +1,250 @@
+
+ */
+class DataProviderDataRule implements Rule
+{
+
+ private TestMethodsHelper $testMethodsHelper;
+
+ private DataProviderHelper $dataProviderHelper;
+
+ private PHPUnitVersion $PHPUnitVersion;
+
+ public function __construct(
+ TestMethodsHelper $testMethodsHelper,
+ DataProviderHelper $dataProviderHelper,
+ PHPUnitVersion $PHPUnitVersion
+ )
+ {
+ $this->testMethodsHelper = $testMethodsHelper;
+ $this->dataProviderHelper = $dataProviderHelper;
+ $this->PHPUnitVersion = $PHPUnitVersion;
+ }
+
+ public function getNodeType(): string
+ {
+ return Node::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ if (
+ !$node instanceof Node\Stmt\Return_
+ && !$node instanceof Node\Expr\Yield_
+ && !$node instanceof Node\Expr\YieldFrom
+ ) {
+ return [];
+ }
+
+ if ($scope->getFunction() === null) {
+ return [];
+ }
+ if ($scope->isInAnonymousFunction()) {
+ return [];
+ }
+
+ $arraysTypes = $this->buildArrayTypesFromNode($node, $scope);
+ if ($arraysTypes === []) {
+ return [];
+ }
+
+ $method = $scope->getFunction();
+ $classReflection = $scope->getClassReflection();
+ if ($classReflection === null) {
+ return [];
+ }
+
+ $testsWithProvider = [];
+ $testMethods = $this->testMethodsHelper->getTestMethods($classReflection, $scope);
+ foreach ($testMethods as $testMethod) {
+ foreach ($this->dataProviderHelper->getDataProviderMethods($scope, $testMethod, $classReflection) as [, $providerMethodName]) {
+ if ($providerMethodName === $method->getName()) {
+ $testsWithProvider[] = $testMethod;
+ continue 2;
+ }
+ }
+ }
+
+ if (count($testsWithProvider) === 0) {
+ return [];
+ }
+
+ $maxNumberOfParameters = null;
+ foreach ($testsWithProvider as $testMethod) {
+ $num = $testMethod->getNumberOfParameters();
+ if ($testMethod->isVariadic()) {
+ $num = PHP_INT_MAX;
+ }
+ if ($maxNumberOfParameters === null) {
+ $maxNumberOfParameters = $num;
+ continue;
+ }
+
+ $maxNumberOfParameters = max($maxNumberOfParameters, $num);
+ if ($num === PHP_INT_MAX) {
+ break;
+ }
+ }
+
+ foreach ($testsWithProvider as $testMethod) {
+ $numberOfParameters = $testMethod->getNumberOfParameters();
+
+ foreach ($arraysTypes as [$startLine, $arraysType]) {
+ $args = $this->arrayItemsToArgs($arraysType, $numberOfParameters);
+ if ($args === null) {
+ continue;
+ }
+
+ if (
+ !$testMethod->isVariadic()
+ && $numberOfParameters !== $maxNumberOfParameters
+ ) {
+ $args = array_slice($args, 0, $numberOfParameters);
+ }
+
+ $scope->invokeNodeCallback(new Node\Expr\MethodCall(
+ new TypeExpr(new ObjectType($classReflection->getName())),
+ $testMethod->getName(),
+ $args,
+ ['startLine' => $startLine],
+ ));
+ }
+ }
+
+ return [];
+ }
+
+ /**
+ * @return array
+ */
+ private function arrayItemsToArgs(Type $array, int $numberOfParameters): ?array
+ {
+ $args = [];
+
+ $constArrays = $array->getConstantArrays();
+ if ($constArrays !== [] && count($constArrays) === 1) {
+ $keyTypes = $constArrays[0]->getKeyTypes();
+ $valueTypes = $constArrays[0]->getValueTypes();
+ } elseif ($array->isArray()->yes()) {
+ $keyTypes = [];
+ $valueTypes = [];
+ for ($i = 0; $i < $numberOfParameters; ++$i) {
+ $keyTypes[$i] = $array->getIterableKeyType();
+ $valueTypes[$i] = $array->getIterableValueType();
+ }
+ } else {
+ return null;
+ }
+
+ foreach ($valueTypes as $i => $valueType) {
+ $key = $keyTypes[$i]->getConstantStrings();
+ if (count($key) > 1) {
+ return null;
+ }
+
+ if (count($key) === 0 || !$this->PHPUnitVersion->supportsNamedArgumentsInDataProvider()->yes()) {
+ $arg = new Node\Arg(new TypeExpr($valueType));
+ $args[] = $arg;
+ continue;
+ }
+
+ $arg = new Node\Arg(
+ new TypeExpr($valueType),
+ false,
+ false,
+ [],
+ new Node\Identifier($key[0]->getValue()),
+ );
+ $args[] = $arg;
+ }
+
+ return $args;
+ }
+
+ /**
+ * @param Node\Stmt\Return_|Node\Expr\Yield_|Node\Expr\YieldFrom $node
+ *
+ * @return list
+ */
+ private function buildArrayTypesFromNode(Node $node, Scope $scope): array
+ {
+ $arraysTypes = [];
+
+ // special case for providers only containing static data, so we get more precise error lines
+ if (
+ ($node instanceof Node\Stmt\Return_ && $node->expr instanceof Node\Expr\Array_)
+ || ($node instanceof Node\Expr\YieldFrom && $node->expr instanceof Node\Expr\Array_)
+ ) {
+ foreach ($node->expr->items as $item) {
+ if (!$item->value instanceof Node\Expr\Array_) {
+ $arraysTypes = [];
+ break;
+ }
+
+ $constArrays = $scope->getType($item->value)->getConstantArrays();
+ if ($constArrays === []) {
+ $arraysTypes = [];
+ break;
+ }
+
+ foreach ($constArrays as $constArray) {
+ $arraysTypes[] = [$item->value->getStartLine(), $constArray];
+ }
+ }
+
+ if ($arraysTypes !== []) {
+ return $arraysTypes;
+ }
+ }
+
+ // general case with less precise error message lines
+ if ($node instanceof Node\Stmt\Return_ || $node instanceof Node\Expr\YieldFrom) {
+ if ($node->expr === null) {
+ return [];
+ }
+
+ $exprType = $scope->getType($node->expr);
+ $exprConstArrays = $exprType->getConstantArrays();
+ foreach ($exprConstArrays as $constArray) {
+ foreach ($constArray->getValueTypes() as $valueType) {
+ foreach ($valueType->getConstantArrays() as $constValueArray) {
+ $arraysTypes[] = [$node->getStartLine(), $constValueArray];
+ }
+ }
+ }
+
+ if ($arraysTypes === []) {
+ foreach ($exprType->getIterableValueType()->getArrays() as $arrayType) {
+ $arraysTypes[] = [$node->getStartLine(), $arrayType];
+ }
+ }
+ } elseif ($node instanceof Node\Expr\Yield_) {
+ if ($node->value === null) {
+ return [];
+ }
+
+ $exprType = $scope->getType($node->value);
+ foreach ($exprType->getConstantArrays() as $constValueArray) {
+ $arraysTypes[] = [$node->getStartLine(), $constValueArray];
+ }
+ }
+
+ return $arraysTypes;
+ }
+
+}
diff --git a/src/Rules/PHPUnit/DataProviderDeclarationRule.php b/src/Rules/PHPUnit/DataProviderDeclarationRule.php
new file mode 100644
index 00000000..1983493c
--- /dev/null
+++ b/src/Rules/PHPUnit/DataProviderDeclarationRule.php
@@ -0,0 +1,78 @@
+
+ */
+class DataProviderDeclarationRule implements Rule
+{
+
+ /**
+ * Data provider helper.
+ *
+ */
+ private DataProviderHelper $dataProviderHelper;
+
+ /**
+ * When set to true, it reports data provider method with incorrect name case.
+ *
+ */
+ private bool $checkFunctionNameCase;
+
+ /**
+ * When phpstan-deprecation-rules is installed, it reports deprecated usages.
+ *
+ */
+ private bool $deprecationRulesInstalled;
+
+ public function __construct(
+ DataProviderHelper $dataProviderHelper,
+ bool $checkFunctionNameCase,
+ bool $deprecationRulesInstalled
+ )
+ {
+ $this->dataProviderHelper = $dataProviderHelper;
+ $this->checkFunctionNameCase = $checkFunctionNameCase;
+ $this->deprecationRulesInstalled = $deprecationRulesInstalled;
+ }
+
+ public function getNodeType(): string
+ {
+ return Node\Stmt\ClassMethod::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ $classReflection = $scope->getClassReflection();
+
+ if ($classReflection === null || !$classReflection->is(TestCase::class)) {
+ return [];
+ }
+
+ $errors = [];
+
+ foreach ($this->dataProviderHelper->getDataProviderMethods($scope, $node, $classReflection) as $dataProviderValue => [$dataProviderClassReflection, $dataProviderMethodName, $lineNumber]) {
+ $errors = array_merge(
+ $errors,
+ $this->dataProviderHelper->processDataProvider(
+ $dataProviderValue,
+ $dataProviderClassReflection,
+ $dataProviderMethodName,
+ $lineNumber,
+ $this->checkFunctionNameCase,
+ $this->deprecationRulesInstalled,
+ ),
+ );
+ }
+
+ return $errors;
+ }
+
+}
diff --git a/src/Rules/PHPUnit/DataProviderHelper.php b/src/Rules/PHPUnit/DataProviderHelper.php
new file mode 100644
index 00000000..d40d05e4
--- /dev/null
+++ b/src/Rules/PHPUnit/DataProviderHelper.php
@@ -0,0 +1,362 @@
+reflectionProvider = $reflectionProvider;
+ $this->fileTypeMapper = $fileTypeMapper;
+ $this->parser = $parser;
+ $this->PHPUnitVersion = $PHPUnitVersion;
+ }
+
+ /**
+ * @param ReflectionMethod|ClassMethod $testMethod
+ *
+ * @return iterable
+ */
+ public function getDataProviderMethods(
+ Scope $scope,
+ $testMethod,
+ ClassReflection $classReflection
+ ): iterable
+ {
+ yield from $this->yieldDataProviderAnnotations($testMethod, $scope, $classReflection);
+
+ if (!$this->PHPUnitVersion->supportsDataProviderAttribute()->yes()) {
+ return;
+ }
+
+ yield from $this->yieldDataProviderAttributes($testMethod, $classReflection);
+ }
+
+ /**
+ * @return array
+ */
+ private function getDataProviderAnnotations(?ResolvedPhpDocBlock $phpDoc): array
+ {
+ if ($phpDoc === null) {
+ return [];
+ }
+
+ $phpDocNodes = $phpDoc->getPhpDocNodes();
+
+ $annotations = [];
+
+ foreach ($phpDocNodes as $docNode) {
+ $annotations = array_merge(
+ $annotations,
+ $docNode->getTagsByName('@dataProvider'),
+ );
+ }
+
+ return $annotations;
+ }
+
+ /**
+ * @return list errors
+ */
+ public function processDataProvider(
+ string $dataProviderValue,
+ ?ClassReflection $classReflection,
+ string $methodName,
+ int $lineNumber,
+ bool $checkFunctionNameCase,
+ bool $deprecationRulesInstalled
+ ): array
+ {
+ if ($classReflection === null) {
+ return [
+ RuleErrorBuilder::message(sprintf(
+ '@dataProvider %s related class not found.',
+ $dataProviderValue,
+ ))
+ ->line($lineNumber)
+ ->identifier('phpunit.dataProviderClass')
+ ->build(),
+ ];
+ }
+
+ try {
+ $dataProviderMethodReflection = $classReflection->getNativeMethod($methodName);
+ } catch (MissingMethodFromReflectionException $missingMethodFromReflectionException) {
+ return [
+ RuleErrorBuilder::message(sprintf(
+ '@dataProvider %s related method not found.',
+ $dataProviderValue,
+ ))
+ ->line($lineNumber)
+ ->identifier('phpunit.dataProviderMethod')
+ ->build(),
+ ];
+ }
+
+ $errors = [];
+
+ if ($checkFunctionNameCase && $methodName !== $dataProviderMethodReflection->getName()) {
+ $errors[] = RuleErrorBuilder::message(sprintf(
+ '@dataProvider %s related method is used with incorrect case: %s.',
+ $dataProviderValue,
+ $dataProviderMethodReflection->getName(),
+ ))
+ ->line($lineNumber)
+ ->identifier('method.nameCase')
+ ->build();
+ }
+
+ if (!$dataProviderMethodReflection->isPublic()) {
+ $errors[] = RuleErrorBuilder::message(sprintf(
+ '@dataProvider %s related method must be public.',
+ $dataProviderValue,
+ ))
+ ->line($lineNumber)
+ ->identifier('phpunit.dataProviderPublic')
+ ->build();
+ }
+
+ if (
+ $deprecationRulesInstalled
+ && $this->PHPUnitVersion->requiresStaticDataProviders()->yes()
+ && !$dataProviderMethodReflection->isStatic()
+ ) {
+ $errorBuilder = RuleErrorBuilder::message(sprintf(
+ '@dataProvider %s related method must be static in PHPUnit 10 and newer.',
+ $dataProviderValue,
+ ))
+ ->line($lineNumber)
+ ->identifier('phpunit.dataProviderStatic');
+
+ $dataProviderMethodReflectionDeclaringClass = $dataProviderMethodReflection->getDeclaringClass();
+ if ($dataProviderMethodReflectionDeclaringClass->getFileName() !== null) {
+ $stmts = $this->parser->parseFile($dataProviderMethodReflectionDeclaringClass->getFileName());
+ $nodeFinder = new NodeFinder();
+ /** @var ClassMethod|null $methodNode */
+ $methodNode = $nodeFinder->findFirst($stmts, static fn ($node) => $node instanceof ClassMethod && $node->name->toString() === $dataProviderMethodReflection->getName());
+ if ($methodNode !== null) {
+ $errorBuilder->fixNode($methodNode, static function (ClassMethod $methodNode) {
+ $methodNode->flags |= Modifiers::STATIC;
+
+ return $methodNode;
+ });
+ }
+ }
+ $errors[] = $errorBuilder->build();
+ }
+
+ return $errors;
+ }
+
+ private function getDataProviderAnnotationValue(PhpDocTagNode $phpDocTag): ?string
+ {
+ if (preg_match('/^[^ \t]+/', (string) $phpDocTag->value, $matches) !== 1) {
+ return null;
+ }
+
+ return $matches[0];
+ }
+
+ /**
+ * @return array{ClassReflection|null, string}
+ */
+ private function parseDataProviderAnnotationValue(Scope $scope, string $dataProviderValue): array
+ {
+ $parts = explode('::', $dataProviderValue, 2);
+ if (count($parts) <= 1) {
+ return [$scope->getClassReflection(), $dataProviderValue];
+ }
+
+ if ($this->reflectionProvider->hasClass($parts[0])) {
+ return [$this->reflectionProvider->getClass($parts[0]), $parts[1]];
+ }
+
+ return [null, $dataProviderValue];
+ }
+
+ /**
+ * @return array|null
+ */
+ private function parseDataProviderExternalAttribute(Attribute $attribute): ?array
+ {
+ if (count($attribute->args) !== 2) {
+ return null;
+ }
+ $methodNameArg = $attribute->args[1]->value;
+ if (!$methodNameArg instanceof String_) {
+ return null;
+ }
+ $classNameArg = $attribute->args[0]->value;
+ if ($classNameArg instanceof ClassConstFetch && $classNameArg->class instanceof Name) {
+ $className = $classNameArg->class->toString();
+ } elseif ($classNameArg instanceof String_) {
+ $className = $classNameArg->value;
+ } else {
+ return null;
+ }
+
+ $dataProviderClassReflection = null;
+ if ($this->reflectionProvider->hasClass($className)) {
+ $dataProviderClassReflection = $this->reflectionProvider->getClass($className);
+ $className = $dataProviderClassReflection->getName();
+ }
+
+ return [
+ sprintf('%s::%s', $className, $methodNameArg->value) => [
+ $dataProviderClassReflection,
+ $methodNameArg->value,
+ $attribute->getStartLine(),
+ ],
+ ];
+ }
+
+ /**
+ * @return array|null
+ */
+ private function parseDataProviderAttribute(Attribute $attribute, ClassReflection $classReflection): ?array
+ {
+ if (count($attribute->args) !== 1) {
+ return null;
+ }
+ $methodNameArg = $attribute->args[0]->value;
+ if (!$methodNameArg instanceof String_) {
+ return null;
+ }
+
+ return [
+ $methodNameArg->value => [
+ $classReflection,
+ $methodNameArg->value,
+ $attribute->getStartLine(),
+ ],
+ ];
+ }
+
+ /**
+ * @param ReflectionMethod|ClassMethod $node
+ *
+ * @return iterable
+ */
+ private function yieldDataProviderAttributes($node, ClassReflection $classReflection): iterable
+ {
+ if (
+ $node instanceof ReflectionMethod
+ ) {
+ /** @phpstan-ignore function.alreadyNarrowedType */
+ if (!method_exists($node, 'getAttributes')) {
+ return;
+ }
+
+ foreach ($node->getAttributes('PHPUnit\Framework\Attributes\DataProvider') as $attr) {
+ $args = $attr->getArguments();
+ if (count($args) !== 1) {
+ continue;
+ }
+
+ $startLine = $node->getStartLine();
+ if ($startLine === false) {
+ $startLine = -1;
+ }
+
+ yield [$classReflection, $args[0], $startLine];
+ }
+
+ return;
+ }
+
+ foreach ($node->attrGroups as $attrGroup) {
+ foreach ($attrGroup->attrs as $attr) {
+ $dataProviderMethod = null;
+ if ($attr->name->toLowerString() === 'phpunit\\framework\\attributes\\dataprovider') {
+ $dataProviderMethod = $this->parseDataProviderAttribute($attr, $classReflection);
+ } elseif ($attr->name->toLowerString() === 'phpunit\\framework\\attributes\\dataproviderexternal') {
+ $dataProviderMethod = $this->parseDataProviderExternalAttribute($attr);
+ }
+ if ($dataProviderMethod === null) {
+ continue;
+ }
+
+ yield from $dataProviderMethod;
+ }
+ }
+ }
+
+ /**
+ * @param ReflectionMethod|ClassMethod $node
+ *
+ * @return iterable
+ */
+ private function yieldDataProviderAnnotations($node, Scope $scope, ClassReflection $classReflection): iterable
+ {
+ $docComment = $node->getDocComment();
+ if ($docComment === null || $docComment === false) {
+ return;
+ }
+
+ $methodPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc(
+ $scope->getFile(),
+ $classReflection->getName(),
+ $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null,
+ $node instanceof ClassMethod ? $node->name->toString() : $node->getName(),
+ $docComment instanceof Doc ? $docComment->getText() : $docComment,
+ );
+ foreach ($this->getDataProviderAnnotations($methodPhpDoc) as $annotation) {
+ $dataProviderValue = $this->getDataProviderAnnotationValue($annotation);
+ if ($dataProviderValue === null) {
+ // Missing value is already handled in NoMissingSpaceInMethodAnnotationRule
+ continue;
+ }
+
+ $startLine = $node->getStartLine();
+ if ($startLine === false) {
+ $startLine = -1;
+ }
+
+ $dataProviderMethod = $this->parseDataProviderAnnotationValue($scope, $dataProviderValue);
+ $dataProviderMethod[] = $startLine;
+
+ yield $dataProviderValue => $dataProviderMethod;
+ }
+ }
+
+}
diff --git a/src/Rules/PHPUnit/DataProviderHelperFactory.php b/src/Rules/PHPUnit/DataProviderHelperFactory.php
new file mode 100644
index 00000000..33bbe22d
--- /dev/null
+++ b/src/Rules/PHPUnit/DataProviderHelperFactory.php
@@ -0,0 +1,38 @@
+reflectionProvider = $reflectionProvider;
+ $this->fileTypeMapper = $fileTypeMapper;
+ $this->parser = $parser;
+ $this->PHPUnitVersion = $PHPUnitVersion;
+ }
+
+ public function create(): DataProviderHelper
+ {
+ return new DataProviderHelper($this->reflectionProvider, $this->fileTypeMapper, $this->parser, $this->PHPUnitVersion);
+ }
+
+}
diff --git a/src/Rules/PHPUnit/MockMethodCallRule.php b/src/Rules/PHPUnit/MockMethodCallRule.php
new file mode 100644
index 00000000..6c3b0dc4
--- /dev/null
+++ b/src/Rules/PHPUnit/MockMethodCallRule.php
@@ -0,0 +1,107 @@
+
+ */
+class MockMethodCallRule implements Rule
+{
+
+ public function getNodeType(): string
+ {
+ return Node\Expr\MethodCall::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ if (!$node->name instanceof Node\Identifier || $node->name->name !== 'method') {
+ return [];
+ }
+
+ if (count($node->getArgs()) < 1) {
+ return [];
+ }
+
+ $argType = $scope->getType($node->getArgs()[0]->value);
+ if (count($argType->getConstantStrings()) === 0) {
+ return [];
+ }
+
+ $errors = [];
+ foreach ($argType->getConstantStrings() as $constantString) {
+ $method = $constantString->getValue();
+ $type = $scope->getType($node->var);
+
+ $error = $this->checkCallOnType($scope, $type, $method);
+ if ($error !== null) {
+ $errors[] = $error;
+ continue;
+ }
+
+ if (!$node->var instanceof MethodCall) {
+ continue;
+ }
+
+ if (!$node->var->name instanceof Node\Identifier) {
+ continue;
+ }
+
+ if ($node->var->name->toLowerString() !== 'expects') {
+ continue;
+ }
+
+ $varType = $scope->getType($node->var->var);
+ $error = $this->checkCallOnType($scope, $varType, $method);
+ if ($error === null) {
+ continue;
+ }
+
+ $errors[] = $error;
+ }
+
+ return $errors;
+ }
+
+ private function checkCallOnType(Scope $scope, Type $type, string $method): ?IdentifierRuleError
+ {
+ $methodReflection = $scope->getMethodReflection($type, $method);
+ if ($methodReflection !== null) {
+ return null;
+ }
+
+ if (
+ in_array(MockObject::class, $type->getObjectClassNames(), true)
+ || in_array(Stub::class, $type->getObjectClassNames(), true)
+ ) {
+ $mockClasses = array_filter($type->getObjectClassNames(), static fn (string $class): bool => $class !== MockObject::class && $class !== Stub::class);
+ if (count($mockClasses) === 0) {
+ return null;
+ }
+
+ return RuleErrorBuilder::message(sprintf(
+ 'Trying to mock an undefined method %s() on class %s.',
+ $method,
+ implode('&', $mockClasses),
+ ))->identifier('phpunit.mockMethod')->build();
+ }
+
+ return null;
+ }
+
+}
diff --git a/src/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRule.php b/src/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRule.php
new file mode 100644
index 00000000..a2fc39f1
--- /dev/null
+++ b/src/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRule.php
@@ -0,0 +1,48 @@
+
+ */
+class NoMissingSpaceInClassAnnotationRule implements Rule
+{
+
+ /**
+ * Covers helper.
+ *
+ */
+ private AnnotationHelper $annotationHelper;
+
+ public function __construct(AnnotationHelper $annotationHelper)
+ {
+ $this->annotationHelper = $annotationHelper;
+ }
+
+ public function getNodeType(): string
+ {
+ return InClassNode::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ $classReflection = $scope->getClassReflection();
+ if ($classReflection === null || $classReflection->is(TestCase::class) === false) {
+ return [];
+ }
+
+ $docComment = $node->getDocComment();
+ if ($docComment === null) {
+ return [];
+ }
+
+ return $this->annotationHelper->processDocComment($docComment);
+ }
+
+}
diff --git a/src/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRule.php b/src/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRule.php
new file mode 100644
index 00000000..906e60b1
--- /dev/null
+++ b/src/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRule.php
@@ -0,0 +1,48 @@
+
+ */
+class NoMissingSpaceInMethodAnnotationRule implements Rule
+{
+
+ /**
+ * Covers helper.
+ *
+ */
+ private AnnotationHelper $annotationHelper;
+
+ public function __construct(AnnotationHelper $annotationHelper)
+ {
+ $this->annotationHelper = $annotationHelper;
+ }
+
+ public function getNodeType(): string
+ {
+ return InClassMethodNode::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ $classReflection = $scope->getClassReflection();
+ if ($classReflection === null || $classReflection->is(TestCase::class) === false) {
+ return [];
+ }
+
+ $docComment = $node->getDocComment();
+ if ($docComment === null) {
+ return [];
+ }
+
+ return $this->annotationHelper->processDocComment($docComment);
+ }
+
+}
diff --git a/src/Rules/PHPUnit/PHPUnitVersion.php b/src/Rules/PHPUnit/PHPUnitVersion.php
new file mode 100644
index 00000000..56eb7558
--- /dev/null
+++ b/src/Rules/PHPUnit/PHPUnitVersion.php
@@ -0,0 +1,82 @@
+majorVersion = $majorVersion;
+ $this->minorVersion = $minorVersion;
+ }
+
+ public function supportsDataProviderAttribute(): TrinaryLogic
+ {
+ if ($this->majorVersion === null) {
+ return TrinaryLogic::createMaybe();
+ }
+ return TrinaryLogic::createFromBoolean($this->majorVersion >= 10);
+ }
+
+ public function supportsTestAttribute(): TrinaryLogic
+ {
+ if ($this->majorVersion === null) {
+ return TrinaryLogic::createMaybe();
+ }
+ return TrinaryLogic::createFromBoolean($this->majorVersion >= 10);
+ }
+
+ public function requiresStaticDataProviders(): TrinaryLogic
+ {
+ if ($this->majorVersion === null) {
+ return TrinaryLogic::createMaybe();
+ }
+ return TrinaryLogic::createFromBoolean($this->majorVersion >= 10);
+ }
+
+ public function supportsNamedArgumentsInDataProvider(): TrinaryLogic
+ {
+ if ($this->majorVersion === null) {
+ return TrinaryLogic::createMaybe();
+ }
+ return TrinaryLogic::createFromBoolean($this->majorVersion >= 11);
+ }
+
+ public function requiresPhpversionAttributeWithOperator(): TrinaryLogic
+ {
+ if ($this->majorVersion === null) {
+ return TrinaryLogic::createMaybe();
+ }
+ return TrinaryLogic::createFromBoolean($this->majorVersion >= 13);
+ }
+
+ public function deprecatesPhpversionAttributeWithoutOperator(): TrinaryLogic
+ {
+ return $this->minVersion(12, 4);
+ }
+
+ private function minVersion(int $major, int $minor): TrinaryLogic
+ {
+ if ($this->majorVersion === null || $this->minorVersion === null) {
+ return TrinaryLogic::createMaybe();
+ }
+
+ if ($this->majorVersion > $major) {
+ return TrinaryLogic::createYes();
+ }
+
+ if ($this->majorVersion === $major && $this->minorVersion >= $minor) {
+ return TrinaryLogic::createYes();
+ }
+
+ return TrinaryLogic::createNo();
+ }
+
+}
diff --git a/src/Rules/PHPUnit/PHPUnitVersionDetector.php b/src/Rules/PHPUnit/PHPUnitVersionDetector.php
new file mode 100644
index 00000000..d35c3e72
--- /dev/null
+++ b/src/Rules/PHPUnit/PHPUnitVersionDetector.php
@@ -0,0 +1,50 @@
+getFileName();
+ } catch (ReflectionException $e) {
+ // PHPUnit might not be installed
+ }
+
+ if ($file !== false) {
+ $phpUnitRoot = dirname($file, 3);
+ $phpUnitComposer = $phpUnitRoot . '/composer.json';
+
+ $composerJson = @file_get_contents($phpUnitComposer);
+ if ($composerJson !== false) {
+ $json = json_decode($composerJson, true);
+ $version = $json['extra']['branch-alias']['dev-main'] ?? null;
+ if ($version !== null) {
+ $versionParts = explode('.', $version);
+ $majorVersion = (int) $versionParts[0];
+ $minorVersion = (int) $versionParts[1];
+ }
+ }
+ }
+
+ return new PHPUnitVersion($majorVersion, $minorVersion);
+ }
+
+}
diff --git a/src/Rules/PHPUnit/ShouldCallParentMethodsRule.php b/src/Rules/PHPUnit/ShouldCallParentMethodsRule.php
new file mode 100644
index 00000000..bfd31690
--- /dev/null
+++ b/src/Rules/PHPUnit/ShouldCallParentMethodsRule.php
@@ -0,0 +1,108 @@
+
+ */
+class ShouldCallParentMethodsRule implements Rule
+{
+
+ public function getNodeType(): string
+ {
+ return InClassMethodNode::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ $methodName = $node->getOriginalNode()->name->name;
+ if (!in_array(strtolower($methodName), ['setup', 'teardown'], true)) {
+ return [];
+ }
+ if ($scope->getClassReflection() === null) {
+ return [];
+ }
+
+ if (!$scope->getClassReflection()->is(TestCase::class)) {
+ return [];
+ }
+
+ $parentClass = $scope->getClassReflection()->getParentClass();
+
+ if ($parentClass === null) {
+ return [];
+ }
+ if (!$parentClass->hasNativeMethod($methodName)) {
+ return [];
+ }
+
+ $parentMethod = $parentClass->getNativeMethod($methodName);
+ if ($parentMethod->getDeclaringClass()->getName() === TestCase::class) {
+ return [];
+ }
+
+ $hasParentCall = $this->hasParentClassCall($node->getOriginalNode()->getStmts(), strtolower($methodName));
+
+ if (!$hasParentCall) {
+ return [
+ RuleErrorBuilder::message(
+ sprintf('Missing call to parent::%s() method.', $methodName),
+ )->identifier('phpunit.callParent')->build(),
+ ];
+ }
+
+ return [];
+ }
+
+ /**
+ * @param Node\Stmt[]|null $stmts
+ *
+ */
+ private function hasParentClassCall(?array $stmts, string $methodName): bool
+ {
+ if ($stmts === null) {
+ return false;
+ }
+
+ foreach ($stmts as $stmt) {
+ if (! $stmt instanceof Node\Stmt\Expression) {
+ continue;
+ }
+
+ if (! $stmt->expr instanceof Node\Expr\StaticCall) {
+ continue;
+ }
+
+ if (! $stmt->expr->class instanceof Node\Name) {
+ continue;
+ }
+
+ $class = (string) $stmt->expr->class;
+
+ if (strtolower($class) !== 'parent') {
+ continue;
+ }
+
+ if (! $stmt->expr->name instanceof Node\Identifier) {
+ continue;
+ }
+
+ if ($stmt->expr->name->toLowerString() === $methodName) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+}
diff --git a/src/Rules/PHPUnit/TestMethodsHelper.php b/src/Rules/PHPUnit/TestMethodsHelper.php
new file mode 100644
index 00000000..a215c8f4
--- /dev/null
+++ b/src/Rules/PHPUnit/TestMethodsHelper.php
@@ -0,0 +1,111 @@
+fileTypeMapper = $fileTypeMapper;
+ $this->PHPUnitVersion = $PHPUnitVersion;
+ }
+
+ public function getTestMethodReflection(ClassReflection $classReflection, MethodReflection $methodReflection, Scope $scope): ?ReflectionMethod
+ {
+ foreach ($this->getTestMethods($classReflection, $scope) as $testMethod) {
+ if ($testMethod->getName() === $methodReflection->getName()) {
+ return $testMethod;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @return array
+ */
+ public function getTestMethods(ClassReflection $classReflection, Scope $scope): array
+ {
+ if (!$classReflection->is(TestCase::class)) {
+ return [];
+ }
+
+ $testMethods = [];
+ foreach ($classReflection->getNativeReflection()->getMethods() as $reflectionMethod) {
+ if (!$reflectionMethod->isPublic()) {
+ continue;
+ }
+
+ if (str_starts_with(strtolower($reflectionMethod->getName()), 'test')) {
+ $testMethods[] = $reflectionMethod;
+ continue;
+ }
+
+ $docComment = $reflectionMethod->getDocComment();
+ if ($docComment !== false) {
+ $methodPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc(
+ $scope->getFile(),
+ $classReflection->getName(),
+ $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null,
+ $reflectionMethod->getName(),
+ $docComment,
+ );
+
+ if ($this->hasTestAnnotation($methodPhpDoc)) {
+ $testMethods[] = $reflectionMethod;
+ continue;
+ }
+ }
+
+ if ($this->PHPUnitVersion->supportsTestAttribute()->no()) {
+ continue;
+ }
+
+ $testAttributes = $reflectionMethod->getAttributes('PHPUnit\Framework\Attributes\Test'); // @phpstan-ignore argument.type
+ if ($testAttributes === []) {
+ continue;
+ }
+
+ $testMethods[] = $reflectionMethod;
+ }
+
+ return $testMethods;
+ }
+
+ private function hasTestAnnotation(?ResolvedPhpDocBlock $phpDoc): bool
+ {
+ if ($phpDoc === null) {
+ return false;
+ }
+
+ $phpDocNodes = $phpDoc->getPhpDocNodes();
+
+ foreach ($phpDocNodes as $docNode) {
+ $tags = $docNode->getTagsByName('@test');
+ if ($tags !== []) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+}
diff --git a/src/Type/PHPUnit/Assert/AssertFunctionTypeSpecifyingExtension.php b/src/Type/PHPUnit/Assert/AssertFunctionTypeSpecifyingExtension.php
new file mode 100644
index 00000000..f5a5a745
--- /dev/null
+++ b/src/Type/PHPUnit/Assert/AssertFunctionTypeSpecifyingExtension.php
@@ -0,0 +1,64 @@
+typeSpecifier = $typeSpecifier;
+ }
+
+ public function isFunctionSupported(
+ FunctionReflection $functionReflection,
+ FuncCall $node,
+ TypeSpecifierContext $context
+ ): bool
+ {
+ return AssertTypeSpecifyingExtensionHelper::isSupported(
+ $this->trimName($functionReflection->getName()),
+ $node->getArgs(),
+ );
+ }
+
+ public function specifyTypes(
+ FunctionReflection $functionReflection,
+ FuncCall $node,
+ Scope $scope,
+ TypeSpecifierContext $context
+ ): SpecifiedTypes
+ {
+ return AssertTypeSpecifyingExtensionHelper::specifyTypes(
+ $this->typeSpecifier,
+ $scope,
+ $this->trimName($functionReflection->getName()),
+ $node->getArgs(),
+ );
+ }
+
+ private function trimName(string $functionName): string
+ {
+ $prefix = 'PHPUnit\\Framework\\';
+ if (strpos($functionName, $prefix) === 0) {
+ return substr($functionName, strlen($prefix));
+ }
+
+ return $functionName;
+ }
+
+}
diff --git a/src/Type/PHPUnit/Assert/AssertMethodTypeSpecifyingExtension.php b/src/Type/PHPUnit/Assert/AssertMethodTypeSpecifyingExtension.php
new file mode 100644
index 00000000..753c8b89
--- /dev/null
+++ b/src/Type/PHPUnit/Assert/AssertMethodTypeSpecifyingExtension.php
@@ -0,0 +1,56 @@
+typeSpecifier = $typeSpecifier;
+ }
+
+ public function getClass(): string
+ {
+ return 'PHPUnit\Framework\Assert';
+ }
+
+ public function isMethodSupported(
+ MethodReflection $methodReflection,
+ MethodCall $node,
+ TypeSpecifierContext $context
+ ): bool
+ {
+ return AssertTypeSpecifyingExtensionHelper::isSupported(
+ $methodReflection->getName(),
+ $node->getArgs(),
+ );
+ }
+
+ public function specifyTypes(
+ MethodReflection $functionReflection,
+ MethodCall $node,
+ Scope $scope,
+ TypeSpecifierContext $context
+ ): SpecifiedTypes
+ {
+ return AssertTypeSpecifyingExtensionHelper::specifyTypes(
+ $this->typeSpecifier,
+ $scope,
+ $functionReflection->getName(),
+ $node->getArgs(),
+ );
+ }
+
+}
diff --git a/src/Type/PHPUnit/Assert/AssertStaticMethodTypeSpecifyingExtension.php b/src/Type/PHPUnit/Assert/AssertStaticMethodTypeSpecifyingExtension.php
new file mode 100644
index 00000000..ec0dad14
--- /dev/null
+++ b/src/Type/PHPUnit/Assert/AssertStaticMethodTypeSpecifyingExtension.php
@@ -0,0 +1,56 @@
+typeSpecifier = $typeSpecifier;
+ }
+
+ public function getClass(): string
+ {
+ return 'PHPUnit\Framework\Assert';
+ }
+
+ public function isStaticMethodSupported(
+ MethodReflection $methodReflection,
+ StaticCall $node,
+ TypeSpecifierContext $context
+ ): bool
+ {
+ return AssertTypeSpecifyingExtensionHelper::isSupported(
+ $methodReflection->getName(),
+ $node->getArgs(),
+ );
+ }
+
+ public function specifyTypes(
+ MethodReflection $functionReflection,
+ StaticCall $node,
+ Scope $scope,
+ TypeSpecifierContext $context
+ ): SpecifiedTypes
+ {
+ return AssertTypeSpecifyingExtensionHelper::specifyTypes(
+ $this->typeSpecifier,
+ $scope,
+ $functionReflection->getName(),
+ $node->getArgs(),
+ );
+ }
+
+}
diff --git a/src/Type/PHPUnit/Assert/AssertTypeSpecifyingExtensionHelper.php b/src/Type/PHPUnit/Assert/AssertTypeSpecifyingExtensionHelper.php
new file mode 100644
index 00000000..04def4e3
--- /dev/null
+++ b/src/Type/PHPUnit/Assert/AssertTypeSpecifyingExtensionHelper.php
@@ -0,0 +1,299 @@
+= count($resolverReflection->getMethod('__invoke')->getParameters()) - 1;
+ }
+
+ private static function trimName(string $name): string
+ {
+ if (strpos($name, 'assert') !== 0) {
+ return $name;
+ }
+
+ $name = substr($name, strlen('assert'));
+
+ if (strpos($name, 'Not') === 0) {
+ return substr($name, 3);
+ }
+
+ if (strpos($name, 'IsNot') === 0) {
+ return 'Is' . substr($name, 5);
+ }
+
+ return $name;
+ }
+
+ /**
+ * @param Arg[] $args $args
+ */
+ public static function specifyTypes(
+ TypeSpecifier $typeSpecifier,
+ Scope $scope,
+ string $name,
+ array $args
+ ): SpecifiedTypes
+ {
+ $expression = self::createExpression($scope, $name, $args);
+ if ($expression === null) {
+ return new SpecifiedTypes([], []);
+ }
+
+ $bypassAlwaysTrueIssue = in_array(self::trimName($name), self::$resolversCausingAlwaysTrue, true);
+
+ return $typeSpecifier->specifyTypesInCondition(
+ $scope,
+ $expression,
+ TypeSpecifierContext::createTruthy(),
+ )->setRootExpr($bypassAlwaysTrueIssue ? new Expr\BinaryOp\BooleanAnd($expression, new Expr\Variable('nonsense')) : $expression);
+ }
+
+ /**
+ * @param Arg[] $args
+ */
+ private static function createExpression(
+ Scope $scope,
+ string $name,
+ array $args
+ ): ?Expr
+ {
+ $trimmedName = self::trimName($name);
+ $resolvers = self::getExpressionResolvers();
+ $resolver = $resolvers[$trimmedName];
+ $expression = $resolver($scope, ...$args);
+ if ($expression === null) {
+ return null;
+ }
+
+ if (strpos($name, 'Not') !== false) {
+ $expression = new BooleanNot($expression);
+ }
+
+ return $expression;
+ }
+
+ /**
+ * @return Closure[]
+ */
+ private static function getExpressionResolvers(): array
+ {
+ if (self::$resolvers === null) {
+ self::$resolvers = [
+ 'Count' => static fn (Scope $scope, Arg $expected, Arg $actual): Identical => new Identical(
+ $expected->value,
+ new FuncCall(new Name('count'), [$actual]),
+ ),
+ 'NotCount' => static fn (Scope $scope, Arg $expected, Arg $actual): BooleanNot => new BooleanNot(
+ new Identical(
+ $expected->value,
+ new FuncCall(new Name('count'), [$actual]),
+ ),
+ ),
+ 'InstanceOf' => static fn (Scope $scope, Arg $class, Arg $object): Instanceof_ => new Instanceof_(
+ $object->value,
+ $class->value,
+ ),
+ 'Same' => static fn (Scope $scope, Arg $expected, Arg $actual): Identical => new Identical(
+ $expected->value,
+ $actual->value,
+ ),
+ 'True' => static fn (Scope $scope, Arg $actual): Identical => new Identical(
+ $actual->value,
+ new ConstFetch(new Name('true')),
+ ),
+ 'False' => static fn (Scope $scope, Arg $actual): Identical => new Identical(
+ $actual->value,
+ new ConstFetch(new Name('false')),
+ ),
+ 'Null' => static fn (Scope $scope, Arg $actual): Identical => new Identical(
+ $actual->value,
+ new ConstFetch(new Name('null')),
+ ),
+ 'Empty' => static fn (Scope $scope, Arg $actual): Expr\BinaryOp\BooleanOr => new Expr\BinaryOp\BooleanOr(
+ new Instanceof_($actual->value, new Name(EmptyIterator::class)),
+ new Expr\BinaryOp\BooleanOr(
+ new Expr\BinaryOp\BooleanAnd(
+ new Instanceof_($actual->value, new Name(Countable::class)),
+ new Identical(new FuncCall(new Name('count'), [new Arg($actual->value)]), new LNumber(0)),
+ ),
+ new Expr\Empty_($actual->value),
+ ),
+ ),
+ 'IsArray' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_array'), [$actual]),
+ 'IsBool' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_bool'), [$actual]),
+ 'IsCallable' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_callable'), [$actual]),
+ 'IsFloat' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_float'), [$actual]),
+ 'IsInt' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_int'), [$actual]),
+ 'IsIterable' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_iterable'), [$actual]),
+ 'IsNumeric' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_numeric'), [$actual]),
+ 'IsObject' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_object'), [$actual]),
+ 'IsResource' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_resource'), [$actual]),
+ 'IsString' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_string'), [$actual]),
+ 'IsScalar' => static fn (Scope $scope, Arg $actual): FuncCall => new FuncCall(new Name('is_scalar'), [$actual]),
+ 'InternalType' => static function (Scope $scope, Arg $type, Arg $value): ?FuncCall {
+ $typeNames = $scope->getType($type->value)->getConstantStrings();
+ if (count($typeNames) !== 1) {
+ return null;
+ }
+
+ switch ($typeNames[0]->getValue()) {
+ case 'numeric':
+ $functionName = 'is_numeric';
+ break;
+ case 'integer':
+ case 'int':
+ $functionName = 'is_int';
+ break;
+
+ case 'double':
+ case 'float':
+ case 'real':
+ $functionName = 'is_float';
+ break;
+
+ case 'string':
+ $functionName = 'is_string';
+ break;
+
+ case 'boolean':
+ case 'bool':
+ $functionName = 'is_bool';
+ break;
+
+ case 'scalar':
+ $functionName = 'is_scalar';
+ break;
+
+ case 'null':
+ $functionName = 'is_null';
+ break;
+
+ case 'array':
+ $functionName = 'is_array';
+ break;
+
+ case 'object':
+ $functionName = 'is_object';
+ break;
+
+ case 'resource':
+ $functionName = 'is_resource';
+ break;
+
+ case 'callable':
+ $functionName = 'is_callable';
+ break;
+ default:
+ return null;
+ }
+
+ return new FuncCall(
+ new Name($functionName),
+ [
+ $value,
+ ],
+ );
+ },
+ 'ArrayHasKey' => static fn (Scope $scope, Arg $key, Arg $array): Expr => new Expr\BinaryOp\BooleanOr(
+ new Expr\BinaryOp\BooleanAnd(
+ new Expr\Instanceof_($array->value, new Name('ArrayAccess')),
+ new Expr\MethodCall($array->value, 'offsetExists', [$key]),
+ ),
+ new FuncCall(new Name('array_key_exists'), [$key, $array]),
+ ),
+ 'ObjectHasAttribute' => static fn (Scope $scope, Arg $property, Arg $object): FuncCall => new FuncCall(new Name('property_exists'), [$object, $property]),
+ 'ObjectHasProperty' => static fn (Scope $scope, Arg $property, Arg $object): FuncCall => new FuncCall(new Name('property_exists'), [$object, $property]),
+ 'Contains' => static fn (Scope $scope, Arg $needle, Arg $haystack): Expr => new Expr\BinaryOp\BooleanOr(
+ new Expr\Instanceof_($haystack->value, new Name('Traversable')),
+ new FuncCall(new Name('in_array'), [$needle, $haystack, new Arg(new ConstFetch(new Name('true')))]),
+ ),
+ 'ContainsEquals' => static fn (Scope $scope, Arg $needle, Arg $haystack): Expr => new Expr\BinaryOp\BooleanOr(
+ new Expr\Instanceof_($haystack->value, new Name('Traversable')),
+ new Expr\BinaryOp\BooleanAnd(
+ new Expr\BooleanNot(new Expr\Empty_($haystack->value)),
+ new FuncCall(new Name('in_array'), [$needle, $haystack, new Arg(new ConstFetch(new Name('false')))]),
+ ),
+ ),
+ 'ContainsOnlyInstancesOf' => static fn (Scope $scope, Arg $className, Arg $haystack): Expr => new Expr\BinaryOp\BooleanOr(
+ new Expr\Instanceof_($haystack->value, new Name('Traversable')),
+ new Identical(
+ $haystack->value,
+ new FuncCall(new Name('array_filter'), [
+ $haystack,
+ new Arg(new Expr\Closure([
+ 'static' => true,
+ 'params' => [
+ new Param(new Expr\Variable('_')),
+ ],
+ 'stmts' => [
+ new Stmt\Return_(
+ new FuncCall(new Name('is_a'), [new Arg(new Expr\Variable('_')), $className]),
+ ),
+ ],
+ ])),
+ ]),
+ ),
+ ),
+ ];
+ }
+
+ return self::$resolvers;
+ }
+
+}
diff --git a/src/Type/PHPUnit/CreateMockDynamicReturnTypeExtension.php b/src/Type/PHPUnit/CreateMockDynamicReturnTypeExtension.php
deleted file mode 100644
index 68038e04..00000000
--- a/src/Type/PHPUnit/CreateMockDynamicReturnTypeExtension.php
+++ /dev/null
@@ -1,65 +0,0 @@
- 0,
- 'createConfiguredMock' => 0,
- 'getMockForAbstractClass' => 0,
- 'getMockFromWsdl' => 1,
- ];
-
- public function getClass(): string
- {
- return \PHPUnit\Framework\TestCase::class;
- }
-
- public function isMethodSupported(MethodReflection $methodReflection): bool
- {
- return array_key_exists($methodReflection->getName(), $this->methods);
- }
-
- public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type
- {
- $argumentIndex = $this->methods[$methodReflection->getName()];
- if (!isset($methodCall->args[$argumentIndex])) {
- return $methodReflection->getReturnType();
- }
- $arg = $methodCall->args[$argumentIndex]->value;
- if (!($arg instanceof \PhpParser\Node\Expr\ClassConstFetch)) {
- return $methodReflection->getReturnType();
- }
-
- $class = $arg->class;
- if (!($class instanceof \PhpParser\Node\Name)) {
- return $methodReflection->getReturnType();
- }
-
- $class = (string) $class;
-
- if ($class === 'static') {
- return $methodReflection->getReturnType();
- }
-
- if ($class === 'self') {
- $class = $scope->getClassReflection()->getName();
- }
-
- return TypeCombinator::intersect(
- new ObjectType($class),
- $methodReflection->getReturnType()
- );
- }
-
-}
diff --git a/src/Type/PHPUnit/DataProviderReturnTypeIgnoreExtension.php b/src/Type/PHPUnit/DataProviderReturnTypeIgnoreExtension.php
new file mode 100644
index 00000000..be6af678
--- /dev/null
+++ b/src/Type/PHPUnit/DataProviderReturnTypeIgnoreExtension.php
@@ -0,0 +1,56 @@
+testMethodsHelper = $testMethodsHelper;
+ $this->dataProviderHelper = $dataProviderHelper;
+ }
+
+ public function shouldIgnore(Error $error, Node $node, Scope $scope): bool
+ {
+ if ($error->getIdentifier() !== 'missingType.iterableValue') {
+ return false;
+ }
+
+ if (!$scope->isInClass()) {
+ return false;
+ }
+ $classReflection = $scope->getClassReflection();
+
+ $methodReflection = $scope->getFunction();
+ if ($methodReflection === null) {
+ return false;
+ }
+
+ $testMethods = $this->testMethodsHelper->getTestMethods($classReflection, $scope);
+ foreach ($testMethods as $testMethod) {
+ foreach ($this->dataProviderHelper->getDataProviderMethods($scope, $testMethod, $classReflection) as [, $providerMethodName]) {
+ if ($providerMethodName === $methodReflection->getName()) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+}
diff --git a/src/Type/PHPUnit/GetMockBuilderDynamicReturnTypeExtension.php b/src/Type/PHPUnit/GetMockBuilderDynamicReturnTypeExtension.php
deleted file mode 100644
index cf35ceb1..00000000
--- a/src/Type/PHPUnit/GetMockBuilderDynamicReturnTypeExtension.php
+++ /dev/null
@@ -1,56 +0,0 @@
-getName() === 'getMockBuilder';
- }
-
- public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type
- {
- $mockBuilderType = $methodReflection->getReturnType();
- if (count($methodCall->args) === 0) {
- return $mockBuilderType;
- }
- $arg = $methodCall->args[0]->value;
- if (!($arg instanceof \PhpParser\Node\Expr\ClassConstFetch)) {
- return $mockBuilderType;
- }
-
- $class = $arg->class;
- if (!($class instanceof \PhpParser\Node\Name)) {
- return $mockBuilderType;
- }
-
- $class = (string) $class;
- if ($class === 'static') {
- return $mockBuilderType;
- }
-
- if ($class === 'self') {
- $class = $scope->getClassReflection()->getName();
- }
-
- if (!$mockBuilderType instanceof TypeWithClassName) {
- throw new \PHPStan\ShouldNotHappenException();
- }
-
- return new MockBuilderType($mockBuilderType, $class);
- }
-
-}
diff --git a/src/Type/PHPUnit/MockBuilderDynamicReturnTypeExtension.php b/src/Type/PHPUnit/MockBuilderDynamicReturnTypeExtension.php
index 4c6afff6..166a9038 100644
--- a/src/Type/PHPUnit/MockBuilderDynamicReturnTypeExtension.php
+++ b/src/Type/PHPUnit/MockBuilderDynamicReturnTypeExtension.php
@@ -4,64 +4,36 @@
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
-use PHPStan\Broker\Broker;
use PHPStan\Reflection\MethodReflection;
-use PHPStan\Type\ObjectType;
+use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\Type;
-use PHPStan\Type\TypeCombinator;
-use PHPStan\Type\TypeWithClassName;
+use PHPUnit\Framework\MockObject\MockBuilder;
+use function in_array;
-class MockBuilderDynamicReturnTypeExtension implements \PHPStan\Type\DynamicMethodReturnTypeExtension, \PHPStan\Reflection\BrokerAwareExtension
+class MockBuilderDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
- /**
- * @var \PHPStan\Broker\Broker
- */
- private $broker;
-
- public function setBroker(Broker $broker)
- {
- $this->broker = $broker;
- }
-
public function getClass(): string
{
- $testCase = $this->broker->getClass(\PHPUnit\Framework\TestCase::class);
- $mockBuilderType = $testCase->getNativeMethod('getMockBuilder')->getReturnType();
- if (!$mockBuilderType instanceof TypeWithClassName) {
- throw new \PHPStan\ShouldNotHappenException();
- }
-
- return $mockBuilderType->getClassName();
+ return MockBuilder::class;
}
public function isMethodSupported(MethodReflection $methodReflection): bool
{
- return true;
- }
-
- public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type
- {
- $calledOnType = $scope->getType($methodCall->var);
- if (!in_array(
+ return !in_array(
$methodReflection->getName(),
[
'getMock',
'getMockForAbstractClass',
+ 'getMockForTrait',
],
- true
- )) {
- return $calledOnType;
- }
-
- if (!$calledOnType instanceof MockBuilderType) {
- return $methodReflection->getReturnType();
- }
-
- return TypeCombinator::intersect(
- new ObjectType($calledOnType->getMockedClass()),
- $methodReflection->getReturnType()
+ true,
);
}
+ public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type
+ {
+ return $scope->getType($methodCall->var);
+ }
+
}
diff --git a/src/Type/PHPUnit/MockBuilderType.php b/src/Type/PHPUnit/MockBuilderType.php
deleted file mode 100644
index a76443e5..00000000
--- a/src/Type/PHPUnit/MockBuilderType.php
+++ /dev/null
@@ -1,34 +0,0 @@
-getClassName());
- $this->mockedClass = $mockedClass;
- }
-
- public function getMockedClass(): string
- {
- return $this->mockedClass;
- }
-
- public function describe(): string
- {
- return sprintf('%s<%s>', parent::describe(), $this->mockedClass);
- }
-
-}
diff --git a/stubs/Assert.stub b/stubs/Assert.stub
new file mode 100644
index 00000000..d9ccd12b
--- /dev/null
+++ b/stubs/Assert.stub
@@ -0,0 +1,13 @@
+ $array
+ *
+ * @throws ExpectationFailedException
+ */
+ final public static function assertIsList(mixed $array, string $message = ''): void {}
+}
diff --git a/stubs/AssertionFailedError.stub b/stubs/AssertionFailedError.stub
new file mode 100644
index 00000000..4ece2a72
--- /dev/null
+++ b/stubs/AssertionFailedError.stub
@@ -0,0 +1,8 @@
+ $type
+ */
+ public function __construct(TestCase $testCase, $type) {}
+
+ /**
+ * @phpstan-return MockObject&TMockedClass
+ */
+ public function getMock() {}
+
+ /**
+ * @phpstan-return MockObject&TMockedClass
+ */
+ public function getMockForAbstractClass() {}
+
+}
diff --git a/stubs/MockObject.stub b/stubs/MockObject.stub
new file mode 100644
index 00000000..b3d6d607
--- /dev/null
+++ b/stubs/MockObject.stub
@@ -0,0 +1,8 @@
+ $originalClassName
+ * @phpstan-return Stub&T
+ */
+ public function createStub($originalClassName) {}
+
+ /**
+ * @template T
+ * @phpstan-param class-string $originalClassName
+ * @phpstan-return MockObject&T
+ */
+ public function createMock($originalClassName) {}
+
+ /**
+ * @template T
+ * @phpstan-param class-string $className
+ * @phpstan-return MockBuilder
+ */
+ public function getMockBuilder(string $className) {}
+
+ /**
+ * @template T
+ * @phpstan-param class-string $originalClassName
+ * @phpstan-return MockObject&T
+ */
+ public function createConfiguredMock($originalClassName) {}
+
+ /**
+ * @template T
+ * @phpstan-param class-string $originalClassName
+ * @phpstan-param string[] $methods
+ * @phpstan-return MockObject&T
+ */
+ public function createPartialMock($originalClassName, array $methods) {}
+
+ /**
+ * @template T
+ * @phpstan-param class-string $originalClassName
+ * @phpstan-return MockObject&T
+ */
+ public function createTestProxy($originalClassName) {}
+
+ /**
+ * @template T
+ * @phpstan-param class-string $originalClassName
+ * @phpstan-param mixed[] $arguments
+ * @phpstan-param string $mockClassName
+ * @phpstan-param bool $callOriginalConstructor
+ * @phpstan-param bool $callOriginalClone
+ * @phpstan-param bool $callAutoload
+ * @phpstan-param string[] $mockedMethods
+ * @phpstan-param bool $cloneArguments
+ * @phpstan-return MockObject&T
+ */
+ protected function getMockForAbstractClass($originalClassName, array $arguments = [], $mockClassName = '', $callOriginalConstructor = true, $callOriginalClone = true, $callAutoload = true, $mockedMethods = [], $cloneArguments = false) {}
+
+ /**
+ * @template T
+ * @phpstan-param string $wsdlFile
+ * @phpstan-param class-string $originalClassName
+ * @phpstan-param string $mockClassName
+ * @phpstan-param string[] $methods
+ * @phpstan-param bool $callOriginalConstructor
+ * @phpstan-param mixed[] $options
+ * @phpstan-return MockObject&T
+ */
+ protected function getMockFromWsdl($wsdlFile, $originalClassName = '', $mockClassName = '', array $methods = [], $callOriginalConstructor = true, array $options = []) {}
+
+}
diff --git a/tests/Rules/Methods/CallMethodsRuleTest.php b/tests/Rules/Methods/CallMethodsRuleTest.php
new file mode 100644
index 00000000..99d572f1
--- /dev/null
+++ b/tests/Rules/Methods/CallMethodsRuleTest.php
@@ -0,0 +1,34 @@
+
+ */
+class CallMethodsRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ return self::getContainer()->getByType(CallMethodsRule::class);
+ }
+
+ public function testBug222(): void
+ {
+ $this->analyse([__DIR__ . '/data/bug-222.php'], []);
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ ];
+ }
+
+}
diff --git a/tests/Rules/Methods/data/bug-222.php b/tests/Rules/Methods/data/bug-222.php
new file mode 100644
index 00000000..d07ca146
--- /dev/null
+++ b/tests/Rules/Methods/data/bug-222.php
@@ -0,0 +1,34 @@
+expects($this->exactly(1))
+ ->method('get')
+ ->with(24)
+ ->willReturn('24');
+
+ $mockService
+ ->method('get')
+ ->with(24)
+ ->willReturn('24');
+
+ $mockService
+ ->expects($this->exactly(1))
+ ->method('get')
+ ->willReturn('24');
+
+ $mockService
+ ->method('get')
+ ->willReturn('24');
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/AssertEqualsIsDiscouragedRuleTest.php b/tests/Rules/PHPUnit/AssertEqualsIsDiscouragedRuleTest.php
new file mode 100644
index 00000000..040c1eee
--- /dev/null
+++ b/tests/Rules/PHPUnit/AssertEqualsIsDiscouragedRuleTest.php
@@ -0,0 +1,47 @@
+
+ */
+final class AssertEqualsIsDiscouragedRuleTest extends RuleTestCase
+{
+
+ private const ERROR_MESSAGE_EQUALS = 'You should use assertSame() instead of assertEquals(), because both values are scalars of the same type';
+ private const ERROR_MESSAGE_NOT_EQUALS = 'You should use assertNotSame() instead of assertNotEquals(), because both values are scalars of the same type';
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/assert-equals-is-discouraged.php'], [
+ [self::ERROR_MESSAGE_EQUALS, 19],
+ [self::ERROR_MESSAGE_EQUALS, 22],
+ [self::ERROR_MESSAGE_EQUALS, 23],
+ [self::ERROR_MESSAGE_EQUALS, 24],
+ [self::ERROR_MESSAGE_EQUALS, 25],
+ [self::ERROR_MESSAGE_EQUALS, 26],
+ [self::ERROR_MESSAGE_EQUALS, 27],
+ [self::ERROR_MESSAGE_EQUALS, 28],
+ [self::ERROR_MESSAGE_EQUALS, 29],
+ [self::ERROR_MESSAGE_EQUALS, 30],
+ [self::ERROR_MESSAGE_EQUALS, 32],
+ [self::ERROR_MESSAGE_NOT_EQUALS, 37],
+ [self::ERROR_MESSAGE_NOT_EQUALS, 38],
+ [self::ERROR_MESSAGE_NOT_EQUALS, 39],
+ ]);
+ }
+
+ public function testFix(): void
+ {
+ $this->fix(__DIR__ . '/data/assert-equals-is-discouraged-fixable.php', __DIR__ . '/data/assert-equals-is-discouraged-fixable.php.fixed');
+ }
+
+ protected function getRule(): Rule
+ {
+ return new AssertEqualsIsDiscouragedRule();
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/AssertSameBooleanExpectedRuleTest.php b/tests/Rules/PHPUnit/AssertSameBooleanExpectedRuleTest.php
new file mode 100644
index 00000000..6dacd685
--- /dev/null
+++ b/tests/Rules/PHPUnit/AssertSameBooleanExpectedRuleTest.php
@@ -0,0 +1,60 @@
+
+ */
+class AssertSameBooleanExpectedRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ return new AssertSameBooleanExpectedRule();
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/assert-same-boolean-expected.php'], [
+ [
+ 'You should use assertTrue() instead of assertSame() when expecting "true"',
+ 10,
+ ],
+ [
+ 'You should use assertFalse() instead of assertSame() when expecting "false"',
+ 11,
+ ],
+ [
+ 'You should use assertTrue() instead of assertSame() when expecting "true"',
+ 26,
+ ],
+ [
+ 'You should use assertTrue() instead of assertSame() when expecting "true"',
+ 74,
+ ],
+ [
+ 'You should use assertFalse() instead of assertSame() when expecting "false"',
+ 75,
+ ],
+ ]);
+ }
+
+ public function testFix(): void
+ {
+ $this->fix(__DIR__ . '/data/assert-same-boolean-expected-fixable.php', __DIR__ . '/data/assert-same-boolean-expected-fixable.php.fixed');
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ ];
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/AssertSameMethodDifferentTypesRuleTest.php b/tests/Rules/PHPUnit/AssertSameMethodDifferentTypesRuleTest.php
new file mode 100644
index 00000000..08986404
--- /dev/null
+++ b/tests/Rules/PHPUnit/AssertSameMethodDifferentTypesRuleTest.php
@@ -0,0 +1,86 @@
+
+ */
+class AssertSameMethodDifferentTypesRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ return self::getContainer()->getByType(ImpossibleCheckTypeMethodCallRule::class);
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/assert-same.php'], [
+ [
+ 'Call to method PHPUnit\Framework\Assert::assertSame() with \'1\' and 1 will always evaluate to false.',
+ 10,
+ ],
+ [
+ 'Call to method PHPUnit\Framework\Assert::assertSame() with \'1\' and stdClass will always evaluate to false.',
+ 11,
+ ],
+ [
+ 'Call to method PHPUnit\Framework\Assert::assertSame() with 1 and string will always evaluate to false.',
+ 12,
+ ],
+ [
+ 'Call to method PHPUnit\Framework\Assert::assertSame() with \'1\' and int will always evaluate to false.',
+ 13,
+ ],
+ [
+ 'Call to method PHPUnit\Framework\Assert::assertSame() with array{\'a\', \'b\'} and array{1, 2} will always evaluate to false.',
+ 14,
+ ],
+ [
+ 'Call to method PHPUnit\Framework\Assert::assertSame() with array and array will always evaluate to false.',
+ 39,
+ 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false> in your %configurationFile%>.',
+ ],
+ [
+ 'Call to method PHPUnit\Framework\Assert::assertSame() with 1 and 1 will always evaluate to true.',
+ 44,
+ ],
+ [
+ 'Call to method PHPUnit\Framework\Assert::assertSame() with array{\'a\'} and array{\'a\', \'b\'} will always evaluate to false.',
+ 45,
+ ],
+ [
+ 'Call to method PHPUnit\Framework\Assert::assertSame() with \'1\' and \'1\' will always evaluate to true.',
+ 46,
+ ],
+ [
+ 'Call to method PHPUnit\Framework\Assert::assertSame() with \'1\' and \'2\' will always evaluate to false.',
+ 47,
+ ],
+ [
+ 'Call to method PHPUnit\Framework\Assert::assertSame() with array{\'a\'} and array{\'a\', 1} will always evaluate to false.',
+ 51,
+ ],
+ [
+ 'Call to method PHPUnit\Framework\Assert::assertSame() with array{\'a\', 2, 3.0} and array{\'a\', 1} will always evaluate to false.',
+ 52,
+ ],
+ ]);
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ __DIR__ . '/../../../vendor/phpstan/phpstan-strict-rules/rules.neon',
+ ];
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/AssertSameNullExpectedRuleTest.php b/tests/Rules/PHPUnit/AssertSameNullExpectedRuleTest.php
new file mode 100644
index 00000000..e29096d9
--- /dev/null
+++ b/tests/Rules/PHPUnit/AssertSameNullExpectedRuleTest.php
@@ -0,0 +1,52 @@
+
+ */
+class AssertSameNullExpectedRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ return new AssertSameNullExpectedRule();
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/assert-same-null-expected.php'], [
+ [
+ 'You should use assertNull() instead of assertSame(null, $actual).',
+ 10,
+ ],
+ [
+ 'You should use assertNull() instead of assertSame(null, $actual).',
+ 24,
+ ],
+ [
+ 'You should use assertNull() instead of assertSame(null, $actual).',
+ 60,
+ ],
+ ]);
+ }
+
+ public function testFix(): void
+ {
+ $this->fix(__DIR__ . '/data/assert-same-null-expected-fixable.php', __DIR__ . '/data/assert-same-null-expected-fixable.php.fixed');
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ ];
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/AssertSameStaticMethodDifferentTypesRuleTest.php b/tests/Rules/PHPUnit/AssertSameStaticMethodDifferentTypesRuleTest.php
new file mode 100644
index 00000000..cc65b273
--- /dev/null
+++ b/tests/Rules/PHPUnit/AssertSameStaticMethodDifferentTypesRuleTest.php
@@ -0,0 +1,61 @@
+
+ */
+class AssertSameStaticMethodDifferentTypesRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ return self::getContainer()->getByType(ImpossibleCheckTypeStaticMethodCallRule::class);
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/assert-same.php'], [
+ [
+ 'Call to static method PHPUnit\Framework\Assert::assertSame() with \'1\' and 2 will always evaluate to false.',
+ 16,
+ ],
+ [
+ 'Call to static method PHPUnit\Framework\Assert::assertSame() with \'1\' and 2 will always evaluate to false.',
+ 17,
+ ],
+ [
+ 'Call to static method PHPUnit\Framework\Assert::assertSame() with \'1\' and 2 will always evaluate to false.',
+ 18,
+ ],
+ [
+ 'Call to static method PHPUnit\Framework\Assert::assertSame() with 1 and 2 will always evaluate to false.',
+ 53,
+ ],
+ [
+ 'Call to static method PHPUnit\Framework\Assert::assertSame() with 1 and 2 will always evaluate to false.',
+ 54,
+ ],
+ [
+ 'Call to static method PHPUnit\Framework\Assert::assertSame() with 1 and 2 will always evaluate to false.',
+ 55,
+ ],
+ ]);
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ __DIR__ . '/../../../vendor/phpstan/phpstan-strict-rules/rules.neon',
+ ];
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/AssertSameWithCountRuleTest.php b/tests/Rules/PHPUnit/AssertSameWithCountRuleTest.php
new file mode 100644
index 00000000..dfb940cf
--- /dev/null
+++ b/tests/Rules/PHPUnit/AssertSameWithCountRuleTest.php
@@ -0,0 +1,55 @@
+
+ */
+class AssertSameWithCountRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ return new AssertSameWithCountRule();
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/assert-same-count.php'], [
+ [
+ 'You should use assertCount($expectedCount, $variable) instead of assertSame($expectedCount, count($variable)).',
+ 10,
+ ],
+ [
+ 'You should use assertCount($expectedCount, $variable) instead of assertSame($expectedCount, count($variable)).',
+ 22,
+ ],
+ [
+ 'You should use assertCount($expectedCount, $variable) instead of assertSame($expectedCount, $variable->count()).',
+ 30,
+ ],
+ [
+ 'You should use assertCount($expectedCount, $variable) instead of assertSame($expectedCount, count($variable)).',
+ 40,
+ ],
+ [
+ 'You should use assertCount($expectedCount, $variable) instead of assertSame($expectedCount, count($variable)).',
+ 45,
+ ],
+ ]);
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ ];
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php
new file mode 100644
index 00000000..92d9715c
--- /dev/null
+++ b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php
@@ -0,0 +1,95 @@
+
+ */
+final class AttributeRequiresPhpVersionRuleTest extends RuleTestCase
+{
+
+ private ?int $phpunitMajorVersion;
+
+ private ?int $phpunitMinorVersion;
+
+ private bool $deprecationRulesInstalled = true;
+
+ public function testRuleOnPHPUnitUnknown(): void
+ {
+ $this->phpunitMajorVersion = null;
+ $this->phpunitMinorVersion = null;
+
+ $this->analyse([__DIR__ . '/data/requires-php-version.php'], []);
+ }
+
+ public function testRuleOnPHPUnit115(): void
+ {
+ $this->phpunitMajorVersion = 11;
+ $this->phpunitMinorVersion = 5;
+
+ $this->analyse([__DIR__ . '/data/requires-php-version.php'], []);
+ }
+
+ public function testRuleOnPHPUnit123(): void
+ {
+ $this->phpunitMajorVersion = 12;
+ $this->phpunitMinorVersion = 3;
+
+ $this->analyse([__DIR__ . '/data/requires-php-version.php'], []);
+ }
+
+ public function testRuleOnPHPUnit124DeprecationsOn(): void
+ {
+ $this->phpunitMajorVersion = 12;
+ $this->phpunitMinorVersion = 4;
+ $this->deprecationRulesInstalled = true;
+
+ $this->analyse([__DIR__ . '/data/requires-php-version.php'], [
+ [
+ 'Version requirement without operator is deprecated.',
+ 12,
+ ],
+ ]);
+ }
+
+ public function testRuleOnPHPUnit124DeprecationsOff(): void
+ {
+ $this->phpunitMajorVersion = 12;
+ $this->phpunitMinorVersion = 4;
+ $this->deprecationRulesInstalled = false;
+
+ $this->analyse([__DIR__ . '/data/requires-php-version.php'], []);
+ }
+
+ public function testRuleOnPHPUnit13(): void
+ {
+ $this->phpunitMajorVersion = 13;
+ $this->phpunitMinorVersion = 0;
+
+ $this->analyse([__DIR__ . '/data/requires-php-version.php'], [
+ [
+ 'Version requirement is missing operator.',
+ 12,
+ ],
+ ]);
+ }
+
+ protected function getRule(): Rule
+ {
+ $phpunitVersion = new PHPUnitVersion($this->phpunitMajorVersion, $this->phpunitMinorVersion);
+
+ return new AttributeRequiresPhpVersionRule(
+ $phpunitVersion,
+ new TestMethodsHelper(
+ self::getContainer()->getByType(FileTypeMapper::class),
+ $phpunitVersion,
+ ),
+ $this->deprecationRulesInstalled,
+ );
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/ClassCoversExistsRuleTest.php b/tests/Rules/PHPUnit/ClassCoversExistsRuleTest.php
new file mode 100644
index 00000000..2a835eaf
--- /dev/null
+++ b/tests/Rules/PHPUnit/ClassCoversExistsRuleTest.php
@@ -0,0 +1,65 @@
+
+ */
+class ClassCoversExistsRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ $reflection = $this->createReflectionProvider();
+
+ return new ClassCoversExistsRule(
+ new CoversHelper($reflection),
+ $reflection,
+ );
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/class-coverage.php'], [
+ [
+ '@coversDefaultClass references an invalid class \Not\A\Class.',
+ 8,
+ ],
+ [
+ '@coversDefaultClass is defined multiple times.',
+ 23,
+ ],
+ [
+ '@covers value \Not\A\Class references an invalid class or function.',
+ 31,
+ ],
+ [
+ '@covers value does not specify anything.',
+ 43,
+ ],
+ [
+ '@covers value NotFullyQualified references an invalid class or function.',
+ 50,
+ 'The @covers annotation requires a fully qualified name.',
+ ],
+ [
+ '@covers value \DateTimeInterface references an interface.',
+ 64,
+ ],
+ ]);
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ ];
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/ClassMethodCoversExistsRuleTest.php b/tests/Rules/PHPUnit/ClassMethodCoversExistsRuleTest.php
new file mode 100644
index 00000000..45e8b1f0
--- /dev/null
+++ b/tests/Rules/PHPUnit/ClassMethodCoversExistsRuleTest.php
@@ -0,0 +1,65 @@
+
+ */
+class ClassMethodCoversExistsRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ $reflection = $this->createReflectionProvider();
+
+ return new ClassMethodCoversExistsRule(
+ new CoversHelper($reflection),
+ self::getContainer()->getByType(FileTypeMapper::class),
+ );
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/method-coverage.php'], [
+ [
+ '@covers value \Not\A\Class::ignoreThis references an invalid method.',
+ 14,
+ ],
+ [
+ '@covers value \PHPUnit\Framework\TestCase::assertNotReal references an invalid method.',
+ 28,
+ ],
+ [
+ '@covers value \Not\A\Class::foo references an invalid method.',
+ 35,
+ ],
+ [
+ '@coversDefaultClass defined on class method testBadCoversDefault.',
+ 50,
+ ],
+ [
+ '@covers value \PHPUnit\Framework\TestCase::assertNotReal references an invalid method.',
+ 62,
+ ],
+ [
+ 'Class already @covers \PHPUnit\Framework\TestCase so the method @covers is redundant.',
+ 85,
+ ],
+ ]);
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ ];
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/DataProviderDataRuleTest.php b/tests/Rules/PHPUnit/DataProviderDataRuleTest.php
new file mode 100644
index 00000000..012fce70
--- /dev/null
+++ b/tests/Rules/PHPUnit/DataProviderDataRuleTest.php
@@ -0,0 +1,356 @@
+
+ */
+class DataProviderDataRuleTest extends RuleTestCase
+{
+ private ?int $phpunitVersion;
+
+ protected function getRule(): Rule
+ {
+ $reflectionProvider = $this->createReflectionProvider();
+ $phpunitVersion = new PHPUnitVersion($this->phpunitVersion, 0);
+
+ /** @var list> $rules */
+ $rules = [
+ new DataProviderDataRule(
+ new TestMethodsHelper(
+ self::getContainer()->getByType(FileTypeMapper::class),
+ $phpunitVersion
+ ),
+ new DataProviderHelper(
+ $reflectionProvider,
+ self::getContainer()->getByType(FileTypeMapper::class),
+ self::getContainer()->getService('defaultAnalysisParser'),
+ $phpunitVersion
+ ),
+ $phpunitVersion,
+ ),
+ self::getContainer()->getByType(CallMethodsRule::class) /** @phpstan-ignore phpstanApi.classConstant */
+ ];
+
+ return new CompositeRule($rules);
+ }
+
+ public function testRule(): void
+ {
+ $this->phpunitVersion = 10;
+
+ $this->analyse([__DIR__ . '/data/data-provider-data.php'], [
+ [
+ 'Parameter #2 $input of method DataProviderDataTest\FooTest::testWithAttribute() expects string, int given.',
+ 24,
+ ],
+ [
+ 'Parameter #2 $input of method DataProviderDataTest\FooTest::testWithAttribute() expects string, false given.',
+ 28,
+ ],
+ [
+ 'Parameter #2 $input of method DataProviderDataTest\BarTest::testWithAnnotation() expects string, int given.',
+ 51,
+ ],
+ [
+ 'Parameter #2 $input of method DataProviderDataTest\BarTest::testWithAnnotation() expects string, false given.',
+ 55,
+ ],
+ [
+ 'Parameter #2 $input of method DataProviderDataTest\YieldTest::myTestMethod() expects string, int given.',
+ 80,
+ ],
+ [
+ 'Parameter #2 $input of method DataProviderDataTest\YieldTest::myTestMethod() expects string, false given.',
+ 86,
+ ],
+ [
+ 'Parameter #2 $input of method DataProviderDataTest\YieldFromTest::myTestMethod() expects string, int given.',
+ 112,
+ ],
+ [
+ 'Parameter #2 $input of method DataProviderDataTest\YieldFromTest::myTestMethod() expects string, false given.',
+ 116,
+ ],
+ [
+ 'Method DataProviderDataTest\DifferentArgumentCount::testFoo() invoked with 3 parameters, 2 required.',
+ 141,
+ ],
+ [
+ 'Method DataProviderDataTest\DifferentArgumentCount::testFoo() invoked with 1 parameter, 2 required.',
+ 146,
+ ],
+ [
+ 'Method DataProviderDataTest\DifferentArgumentCountWithReusedDataprovider::testFoo() invoked with 3 parameters, 2 required.',
+ 177,
+ ],
+ [
+ 'Method DataProviderDataTest\DifferentArgumentCountWithReusedDataprovider::testFoo() invoked with 1 parameter, 2 required.',
+ 182,
+ ],
+ [
+ 'Parameter #2 $input of method DataProviderDataTest\UnionTypeReturnTest::testFoo() expects string, int given.',
+ 216,
+ ],
+ [
+ 'Parameter #2 $input of method DataProviderDataTest\YieldFromExpr::testFoo() expects string, int given.',
+ 236,
+ ],
+ [
+ 'Parameter #2 $input of method DataProviderDataTest\YieldFromExpr::testFoo() expects string, true given.',
+ 238,
+ ],
+ [
+ 'Parameter #1 $si of method DataProviderDataTest\TestInvalidVariadic::testBar() expects int, string given.',
+ 295,
+ ],
+ [
+ 'Parameter #1 $s of method DataProviderDataTest\TestInvalidVariadic::testFoo() expects string, int given.',
+ 296,
+ ],
+ [
+ 'Parameter #1 $si of method DataProviderDataTest\TestInvalidVariadic2::testBar() expects int, string given.',
+ 317,
+ ],
+ [
+ 'Parameter #2 ...$moreS of method DataProviderDataTest\TestInvalidVariadic2::testFoo() expects int, string given.',
+ 317,
+ ],
+ [
+ 'Parameter #4 ...$moreS of method DataProviderDataTest\TestInvalidVariadic2::testFoo() expects int, string given.',
+ 317,
+ ],
+ [
+ 'Parameter #1 $s of method DataProviderDataTest\TestInvalidVariadic2::testFoo() expects string, int given.',
+ 318,
+ ],
+ [
+ 'Parameter #1 $i of method DataProviderDataTest\TestArrayIterator::testBar() expects int, int|string given.',
+ 362,
+ ],
+ [
+ 'Parameter #1 $i of method DataProviderDataTest\TestArrayIterator::testFoo() expects int, int|string given.',
+ 362,
+ ],
+ [
+ 'Parameter #1 $s1 of method DataProviderDataTest\TestArrayIterator::testFooBar() expects string, int|string given.',
+ 362,
+ ],
+ [
+ 'Parameter #1 $si of method DataProviderDataTest\TestWrongTypedIterable::testBar() expects int, string given.',
+ 380,
+ ],
+ [
+ 'Parameter #2 $input of method DataProviderDataTest\AbstractBaseTest::testWithAttribute() expects string, int given.',
+ 407,
+ ],
+ [
+ 'Parameter #2 $input of method DataProviderDataTest\AbstractBaseTest::testWithAttribute() expects string, false given.',
+ 411,
+ ],
+ [
+ 'Parameter #2 $input of method DataProviderDataTest\ConstantArrayUnionTypeReturnTest::testFoo() expects string, int given.',
+ 446,
+ ],
+ [
+ 'Method DataProviderDataTest\ConstantArrayDifferentLengthUnionTypeReturnTest::testFoo() invoked with 3 parameters, 2 required.',
+ 484,
+ ],
+ [
+ 'Parameter #2 $input of method DataProviderDataTest\ConstantArrayDifferentLengthUnionTypeReturnTest::testFoo() expects string, int given.',
+ 484,
+ ],
+ [
+ 'Parameter #2 $input of method DataProviderDataTest\ConstantArrayUnionWithDifferentValueTypeReturnTest::testFoo() expects string, int|string given.',
+ 517,
+ ],
+ ]);
+ }
+
+
+ /**
+ * @dataProvider provideNamedArgumentPHPUnitVersions
+ */
+ #[DataProvider('provideNamedArgumentPHPUnitVersions')]
+ public function testRulePhp8(?int $phpunitVersion): void
+ {
+ if (PHP_VERSION_ID < 80000) {
+ self::markTestSkipped();
+ }
+
+ $this->phpunitVersion = $phpunitVersion;
+
+ if ($phpunitVersion >= 11) {
+ $errors = [
+ [
+ 'Parameter $input of method DataProviderDataTestPhp8\NamedArgsInProvider::testFoo() expects string, int given.',
+ 44
+ ],
+ [
+ 'Parameter $input of method DataProviderDataTestPhp8\NamedArgsInProvider::testFoo() expects string, false given.',
+ 44
+ ],
+ [
+ 'Unknown parameter $wrong in call to method DataProviderDataTestPhp8\TestWrongOffsetNameArrayShapeIterable::testBar().',
+ 58
+ ],
+ [
+ 'Missing parameter $si (int) in call to method DataProviderDataTestPhp8\TestWrongOffsetNameArrayShapeIterable::testBar().',
+ 58
+ ],
+ [
+ 'Parameter $si of method DataProviderDataTestPhp8\TestWrongTypeInArrayShapeIterable::testBar() expects int, string given.',
+ 79
+ ],
+ ];
+ } else {
+ $errors = [
+ [
+ 'Parameter #1 $expectedResult of method DataProviderDataTestPhp8\NamedArgsInProvider::testFoo() expects string, int given.',
+ 44
+ ],
+ [
+ 'Parameter #1 $expectedResult of method DataProviderDataTestPhp8\NamedArgsInProvider::testFoo() expects string, false given.',
+ 44
+ ],
+ [
+ 'Parameter #1 $si of method DataProviderDataTestPhp8\TestWrongOffsetNameArrayShapeIterable::testBar() expects int, string given.',
+ 58
+ ],
+ [
+ 'Parameter #1 $si of method DataProviderDataTestPhp8\TestWrongTypeInArrayShapeIterable::testBar() expects int, string given.',
+ 79
+ ],
+ ];
+ }
+
+ $this->analyse([__DIR__ . '/data/data-provider-data-named.php'], $errors);
+ }
+
+
+ public function testVariadicMethod(): void
+ {
+ $this->phpunitVersion = 10;
+
+ $this->analyse([__DIR__ . '/data/data-provider-variadic-method.php'], [
+ [
+ 'Method DataProviderVariadicMethod\FooTest::testProvide2() invoked with 1 parameter, at least 2 required.',
+ 12,
+ ],
+ [
+ 'Parameter #1 $a of method DataProviderVariadicMethod\FooTest::testProvide() expects int, string given.',
+ 13,
+ ],
+ [
+ 'Method DataProviderVariadicMethod\FooTest::testProvide2() invoked with 1 parameter, at least 2 required.',
+ 13,
+ ],
+ [
+ 'Parameter #1 $a of method DataProviderVariadicMethod\FooTest::testProvide2() expects int, string given.',
+ 13,
+ ],
+ [
+ 'Parameter #2 ...$rest of method DataProviderVariadicMethod\FooTest::testProvide() expects string, int given.',
+ 15,
+ ],
+ [
+ 'Parameter #3 ...$rest of method DataProviderVariadicMethod\FooTest::testProvide() expects string, int given.',
+ 15,
+ ],
+ [
+ 'Parameter #2 $two of method DataProviderVariadicMethod\FooTest::testProvide2() expects string, int given.',
+ 15,
+ ],
+ [
+ 'Parameter #3 ...$rest of method DataProviderVariadicMethod\FooTest::testProvide2() expects string, int given.',
+ 15,
+ ],
+ ]);
+ }
+
+ public function testTrimmingArgs(): void
+ {
+ $this->phpunitVersion = 10;
+
+ $this->analyse([__DIR__ . '/data/data-provider-trimming-args.php'], [
+ [
+ 'Method DataProviderTrimmingArgs\FooTest::testProvide() invoked with 2 parameters, 1 required.',
+ 12,
+ ],
+ [
+ 'Method DataProviderTrimmingArgs\FooTest::testProvide2() invoked with 2 parameters, 1 required.',
+ 12,
+ ],
+ [
+ 'Method DataProviderTrimmingArgs\FooTest::testProvide() invoked with 2 parameters, 1 required.',
+ 13,
+ ],
+ [
+ 'Method DataProviderTrimmingArgs\FooTest::testProvide2() invoked with 2 parameters, 1 required.',
+ 13,
+ ],
+ [
+ 'Parameter #6 ...$m of method DataProviderTrimmingArgs\BazTest::testProvide() expects int, string given.',
+ 90,
+ ],
+ ]);
+ }
+
+ static public function provideNamedArgumentPHPUnitVersions(): iterable
+ {
+ yield [null]; // unknown phpunit version
+
+ if (PHP_VERSION_ID >= 80100) {
+ yield [10]; // PHPUnit 10.x requires PHP 8.1+
+ }
+ if (PHP_VERSION_ID >= 80200) {
+ yield [11]; // PHPUnit 11.x requires PHP 8.2+
+ }
+ }
+
+ /**
+ * @dataProvider provideNamedArgumentPHPUnitVersions
+ */
+ #[DataProvider('provideNamedArgumentPHPUnitVersions')]
+ public function testNamedArgumentsInDataProviders(?int $phpunitVersion): void
+ {
+ $this->phpunitVersion = $phpunitVersion;
+
+ if ($phpunitVersion >= 11) {
+ $errors = [];
+ } else {
+ $errors = [
+ [
+ 'Parameter #1 $int of method DataProviderNamedArgs\FooTest::testFoo() expects int, string given.',
+ 26
+ ],
+ [
+ 'Parameter #2 $string of method DataProviderNamedArgs\FooTest::testFoo() expects string, int given.',
+ 26
+ ],
+ ];
+ }
+
+ $this->analyse([__DIR__ . '/data/data-provider-named-args.php'], $errors);
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ ];
+ }
+}
diff --git a/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php b/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php
new file mode 100644
index 00000000..f63c9e65
--- /dev/null
+++ b/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php
@@ -0,0 +1,130 @@
+
+ */
+class DataProviderDeclarationRuleTest extends RuleTestCase
+{
+ private ?int $phpunitVersion;
+
+ protected function getRule(): Rule
+ {
+ $reflection = $this->createReflectionProvider();
+
+ return new DataProviderDeclarationRule(
+ new DataProviderHelper(
+ $reflection,
+ self::getContainer()->getByType(FileTypeMapper::class),
+ self::getContainer()->getService('defaultAnalysisParser'),
+ new PHPUnitVersion($this->phpunitVersion, 0)
+ ),
+ true,
+ true
+ );
+ }
+
+ /**
+ * @dataProvider provideVersions
+ */
+ #[DataProvider('provideVersions')]
+ public function testRule(?int $version): void
+ {
+ $this->phpunitVersion = $version;
+
+ if ($version >= 10) {
+ $errors = [
+ [
+ '@dataProvider providebaz related method is used with incorrect case: provideBaz.',
+ 16,
+ ],
+ [
+ '@dataProvider provideQux related method must be static in PHPUnit 10 and newer.',
+ 16,
+ ],
+ [
+ '@dataProvider provideQuux related method must be public.',
+ 16,
+ ],
+ [
+ '@dataProvider provideNonExisting related method not found.',
+ 70,
+ ],
+ [
+ '@dataProvider NonExisting::provideNonExisting related class not found.',
+ 70,
+ ],
+ [
+ '@dataProvider provideNonExisting related method not found.',
+ 85,
+ ],
+ [
+ '@dataProvider provideNonExisting2 related method not found.',
+ 86,
+ ],
+ [
+ '@dataProvider ExampleTestCase\\BarTestCase::providetootherclass related method is used with incorrect case: provideToOtherClass.',
+ 87,
+ ],
+ [
+ '@dataProvider ExampleTestCase\\BarTestCase::providetootherclass related method is used with incorrect case: provideToOtherClass.',
+ 88,
+ ],
+ ];
+ } else {
+ $errors = [
+ [
+ '@dataProvider providebaz related method is used with incorrect case: provideBaz.',
+ 16,
+ ],
+ [
+ '@dataProvider provideQuux related method must be public.',
+ 16,
+ ],
+ [
+ '@dataProvider provideNonExisting related method not found.',
+ 70,
+ ],
+ [
+ '@dataProvider NonExisting::provideNonExisting related class not found.',
+ 70,
+ ],
+ ];
+ }
+
+ $this->analyse([__DIR__ . '/data/data-provider-declaration.php'], $errors);
+ }
+
+ static public function provideVersions(): iterable
+ {
+ return [
+ [null],
+ [9],
+ [10]
+ ];
+ }
+
+ public function testFixDataProviderStatic(): void
+ {
+ $this->phpunitVersion = 10;
+
+ $this->fix(__DIR__ . '/data/data-provider-static-fix.php', __DIR__ . '/data/data-provider-static-fix.php.fixed');
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ ];
+ }
+}
diff --git a/tests/Rules/PHPUnit/ImpossibleCheckTypeMethodCallRuleTest.php b/tests/Rules/PHPUnit/ImpossibleCheckTypeMethodCallRuleTest.php
new file mode 100644
index 00000000..4c065466
--- /dev/null
+++ b/tests/Rules/PHPUnit/ImpossibleCheckTypeMethodCallRuleTest.php
@@ -0,0 +1,56 @@
+
+ */
+class ImpossibleCheckTypeMethodCallRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ return self::getContainer()->getByType(ImpossibleCheckTypeMethodCallRule::class);
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/impossible-assert-method-call.php'], [
+ [
+ 'Call to method PHPUnit\Framework\Assert::assertEmpty() with array{} will always evaluate to true.',
+ 14,
+ ],
+ [
+ 'Call to method PHPUnit\Framework\Assert::assertEmpty() with array{1, 2, 3} will always evaluate to false.',
+ 15,
+ ],
+ ]);
+ }
+
+ public function testBug141(): void
+ {
+ $this->analyse([__DIR__ . '/data/bug-141.php'], [
+ [
+ "Call to method PHPUnit\Framework\Assert::assertEmpty() with non-empty-array<'0.6.0'|'1.0.0'|'1.0.x-dev'|'1.1.x-dev'|'9999999-dev'|'dev-feature-b', true> will always evaluate to false.",
+ 23,
+ 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false> in your %configurationFile%>.',
+ ],
+ ]);
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ __DIR__ . '/../../../vendor/phpstan/phpstan-strict-rules/rules.neon',
+ ];
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/MockMethodCallRuleTest.php b/tests/Rules/PHPUnit/MockMethodCallRuleTest.php
new file mode 100644
index 00000000..f7e89c7a
--- /dev/null
+++ b/tests/Rules/PHPUnit/MockMethodCallRuleTest.php
@@ -0,0 +1,58 @@
+
+ */
+class MockMethodCallRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ return new MockMethodCallRule();
+ }
+
+ public function testRule(): void
+ {
+ $expectedErrors = [
+ [
+ 'Trying to mock an undefined method doBadThing() on class MockMethodCall\Bar.',
+ 15,
+ ],
+ [
+ 'Trying to mock an undefined method doBadThing() on class MockMethodCall\Bar.',
+ 20,
+ ],
+ [
+ 'Trying to mock an undefined method doBadThing() on class MockMethodCall\Bar.',
+ 36,
+ ],
+ ];
+
+ $this->analyse([__DIR__ . '/data/mock-method-call.php'], $expectedErrors);
+ }
+
+ public function testBug227(): void
+ {
+ if (PHP_VERSION_ID < 80000) {
+ self::markTestSkipped('Test requires PHP 8.0.');
+ }
+ $this->analyse([__DIR__ . '/data/bug-227.php'], []);
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ ];
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRuleTest.php b/tests/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRuleTest.php
new file mode 100644
index 00000000..e28fde15
--- /dev/null
+++ b/tests/Rules/PHPUnit/NoMissingSpaceInClassAnnotationRuleTest.php
@@ -0,0 +1,87 @@
+
+ */
+class NoMissingSpaceInClassAnnotationRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ return new NoMissingSpaceInClassAnnotationRule(new AnnotationHelper());
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/InvalidClassCoversAnnotation.php'], [
+ [
+ 'Annotation "@backupGlobals" is invalid, "@backupGlobals" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@backupStaticAttributes" is invalid, "@backupStaticAttributes" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@covers\Dummy\Foo::assertSame" is invalid, "@covers" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@covers::assertSame" is invalid, "@covers" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@coversDefaultClass\Dummy\Foo" is invalid, "@coversDefaultClass" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@dataProvider" is invalid, "@dataProvider" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@depends" is invalid, "@depends" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@preserveGlobalState" is invalid, "@preserveGlobalState" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@requires" is invalid, "@requires" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@testDox" is invalid, "@testDox" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@testWith" is invalid, "@testWith" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@ticket" is invalid, "@ticket" should be followed by a space and a value.',
+ 36,
+ ],
+ [
+ 'Annotation "@uses" is invalid, "@uses" should be followed by a space and a value.',
+ 36,
+ ],
+ ]);
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ ];
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRuleTest.php b/tests/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRuleTest.php
new file mode 100644
index 00000000..2926ec93
--- /dev/null
+++ b/tests/Rules/PHPUnit/NoMissingSpaceInMethodAnnotationRuleTest.php
@@ -0,0 +1,87 @@
+
+ */
+class NoMissingSpaceInMethodAnnotationRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ return new NoMissingSpaceInMethodAnnotationRule(new AnnotationHelper());
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/InvalidMethodCoversAnnotation.php'], [
+ [
+ 'Annotation "@backupGlobals" is invalid, "@backupGlobals" should be followed by a space and a value.',
+ 12,
+ ],
+ [
+ 'Annotation "@backupStaticAttributes" is invalid, "@backupStaticAttributes" should be followed by a space and a value.',
+ 19,
+ ],
+ [
+ 'Annotation "@covers\Dummy\Foo::assertSame" is invalid, "@covers" should be followed by a space and a value.',
+ 27,
+ ],
+ [
+ 'Annotation "@covers::assertSame" is invalid, "@covers" should be followed by a space and a value.',
+ 27,
+ ],
+ [
+ 'Annotation "@coversDefaultClass\Dummy\Foo" is invalid, "@coversDefaultClass" should be followed by a space and a value.',
+ 33,
+ ],
+ [
+ 'Annotation "@dataProvider" is invalid, "@dataProvider" should be followed by a space and a value.',
+ 39,
+ ],
+ [
+ 'Annotation "@depends" is invalid, "@depends" should be followed by a space and a value.',
+ 45,
+ ],
+ [
+ 'Annotation "@preserveGlobalState" is invalid, "@preserveGlobalState" should be followed by a space and a value.',
+ 52,
+ ],
+ [
+ 'Annotation "@requires" is invalid, "@requires" should be followed by a space and a value.',
+ 58,
+ ],
+ [
+ 'Annotation "@testDox" is invalid, "@testDox" should be followed by a space and a value.',
+ 64,
+ ],
+ [
+ 'Annotation "@testWith" is invalid, "@testWith" should be followed by a space and a value.',
+ 70,
+ ],
+ [
+ 'Annotation "@ticket" is invalid, "@ticket" should be followed by a space and a value.',
+ 76,
+ ],
+ [
+ 'Annotation "@uses" is invalid, "@uses" should be followed by a space and a value.',
+ 82,
+ ],
+ ]);
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ ];
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/ShouldCallParentMethodsRuleTest.php b/tests/Rules/PHPUnit/ShouldCallParentMethodsRuleTest.php
new file mode 100644
index 00000000..b378c67f
--- /dev/null
+++ b/tests/Rules/PHPUnit/ShouldCallParentMethodsRuleTest.php
@@ -0,0 +1,47 @@
+
+ */
+class ShouldCallParentMethodsRuleTest extends RuleTestCase
+{
+
+ protected function getRule(): Rule
+ {
+ return new ShouldCallParentMethodsRule();
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/missing-parent-method-calls.php'], [
+ [
+ 'Missing call to parent::setUp() method.',
+ 32,
+ ],
+ [
+ 'Missing call to parent::setUp() method.',
+ 55,
+ ],
+ [
+ 'Missing call to parent::tearDown() method.',
+ 63,
+ ],
+ ]);
+ }
+
+ /**
+ * @return string[]
+ */
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/../../../extension.neon',
+ ];
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/data/Foo.php b/tests/Rules/PHPUnit/data/Foo.php
new file mode 100644
index 00000000..c5248d77
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/Foo.php
@@ -0,0 +1,13 @@
+= 5.3
+ * @testDox
+ * @testDox foo bar
+ * @testWith
+ * @testWith ['foo', 'bar']
+ * @ticket
+ * @ticket 1234
+ * @uses
+ * @uses foo
+ */
+class InvalidClassCoversAnnotation extends \PHPUnit\Framework\TestCase
+{
+}
diff --git a/tests/Rules/PHPUnit/data/InvalidMethodCoversAnnotation.php b/tests/Rules/PHPUnit/data/InvalidMethodCoversAnnotation.php
new file mode 100644
index 00000000..9154937b
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/InvalidMethodCoversAnnotation.php
@@ -0,0 +1,83 @@
+= 5.3
+ */
+ public function requiresAnnotation() {}
+
+ /**
+ * @testDox
+ * @testDox foo bar
+ */
+ public function testDox() {}
+
+ /**
+ * @testWith
+ * @testWith ['foo', 'bar']
+ */
+ public function testWith() {}
+
+ /**
+ * @ticket
+ * @ticket 1234
+ */
+ public function ticket() {}
+
+ /**
+ * @uses
+ * @uses foo
+ */
+ public function uses() {}
+}
diff --git a/tests/Rules/PHPUnit/data/assert-equals-is-discouraged-fixable.php b/tests/Rules/PHPUnit/data/assert-equals-is-discouraged-fixable.php
new file mode 100644
index 00000000..5c0c993b
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/assert-equals-is-discouraged-fixable.php
@@ -0,0 +1,24 @@
+assertEquals('', $s);
+ $this->assertNotEquals('', $t);
+ }
+
+ public function doFoo2(string $s, string $t): void
+ {
+ self::assertEquals('', $s);
+ self::assertNotEquals('', $t);
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/data/assert-equals-is-discouraged-fixable.php.fixed b/tests/Rules/PHPUnit/data/assert-equals-is-discouraged-fixable.php.fixed
new file mode 100644
index 00000000..9217e3e1
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/assert-equals-is-discouraged-fixable.php.fixed
@@ -0,0 +1,24 @@
+assertSame('', $s);
+ $this->assertNotSame('', $t);
+ }
+
+ public function doFoo2(string $s, string $t): void
+ {
+ self::assertSame('', $s);
+ self::assertNotSame('', $t);
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/data/assert-equals-is-discouraged.php b/tests/Rules/PHPUnit/data/assert-equals-is-discouraged.php
new file mode 100644
index 00000000..7f4d80e1
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/assert-equals-is-discouraged.php
@@ -0,0 +1,43 @@
+assertSame(5, $integer);
+ static::assertSame(5, $integer);
+
+ $this->assertEquals('', $string);
+ $this->assertEquals(null, $string);
+ static::assertEquals(null, $string);
+ static::assertEquals($nullableString, $string);
+ $this->assertEquals(2, $integer);
+ $this->assertEquals(2.2, $float);
+ static::assertEquals((int) '2', (int) $string);
+ $this->assertEquals(true, $boolean);
+ $this->assertEquals($string, $string);
+ $this->assertEquals($integer, $integer);
+ $this->assertEquals($boolean, $boolean);
+ $this->assertEquals($float, $float);
+ $this->assertEquals($null, $null);
+ $this->assertEquals((string) new Exception(), (string) new Exception());
+ $this->assertEquals([], []);
+ $this->assertEquals(new Exception(), new Exception());
+ static::assertEquals(new Exception(), new Exception());
+
+ $this->assertNotEquals($string, $string);
+ $this->assertNotEquals($integer, $integer);
+ $this->assertNotEquals($boolean, $boolean);
+ $this->assertNotSame(5, $integer);
+ static::assertNotSame(5, $integer);
+ }
+}
diff --git a/tests/Rules/PHPUnit/data/assert-same-boolean-expected-fixable.php b/tests/Rules/PHPUnit/data/assert-same-boolean-expected-fixable.php
new file mode 100644
index 00000000..5d5a3ba4
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/assert-same-boolean-expected-fixable.php
@@ -0,0 +1,21 @@
+assertSame(true, $this->returnBool());
+ self::assertSame(false, $this->returnBool());
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/data/assert-same-boolean-expected-fixable.php.fixed b/tests/Rules/PHPUnit/data/assert-same-boolean-expected-fixable.php.fixed
new file mode 100644
index 00000000..d0bb802a
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/assert-same-boolean-expected-fixable.php.fixed
@@ -0,0 +1,21 @@
+assertTrue($this->returnBool());
+ self::assertFalse($this->returnBool());
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/data/assert-same-boolean-expected.php b/tests/Rules/PHPUnit/data/assert-same-boolean-expected.php
new file mode 100644
index 00000000..d6f7f14a
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/assert-same-boolean-expected.php
@@ -0,0 +1,101 @@
+assertSame(true, 'a');
+ $this->assertSame(false, 'a');
+
+ $truish = true;
+ $this->assertSame($truish, true); // using variable is OK
+
+ $falsish = false;
+ $this->assertSame($falsish, false); // using variable is OK
+
+ /** @var bool $a */
+ $a = null;
+ $this->assertSame($a, 'b'); // OK
+ }
+
+ public function testAssertSameIsDetectedWithDirectAssertAccess()
+ {
+ \PHPUnit\Framework\Assert::assertSame(true, 'foo');
+ }
+
+ public function testConstants(): void
+ {
+ \PHPUnit\Framework\Assert::assertSame(PHPSTAN_PHPUNIT_TRUE, 'foo');
+ \PHPUnit\Framework\Assert::assertSame(PHPSTAN_PHPUNIT_FALSE, 'foo');
+ }
+
+ private const TRUE = true;
+ private const FALSE = false;
+
+ public function testClassConstants(): void
+ {
+ \PHPUnit\Framework\Assert::assertSame(self::TRUE, 'foo');
+ \PHPUnit\Framework\Assert::assertSame(self::FALSE, 'foo');
+ }
+
+ public function returnBool(): bool
+ {
+ return true;
+ }
+
+ /**
+ * @return true
+ */
+ public function returnTrue(): bool
+ {
+ return true;
+ }
+
+ /**
+ * @return false
+ */
+ public function returnFalse(): bool
+ {
+ return false;
+ }
+
+ public function testMethodCalls(): void
+ {
+ \PHPUnit\Framework\Assert::assertSame($this->returnTrue(), 'foo');
+ \PHPUnit\Framework\Assert::assertSame($this->returnFalse(), 'foo');
+ \PHPUnit\Framework\Assert::assertSame($this->returnBool(), 'foo');
+ }
+
+ public function testNonLowercase(): void
+ {
+ \PHPUnit\Framework\Assert::assertSame(True, 'foo');
+ \PHPUnit\Framework\Assert::assertSame(False, 'foo');
+ }
+
+ public function testMaybeTrueFalse(): void
+ {
+ $a = rand(0, 1) ? true : 'foo';
+ \PHPUnit\Framework\Assert::assertSame($a, 'foo');
+ $a = rand(0, 1) ? false : 'foo';
+ \PHPUnit\Framework\Assert::assertSame($a, 'foo');
+ }
+
+ public function testConstMaybeTrueFalse(): void
+ {
+ if (
+ !defined('MY_TEST_CONST')
+ ) {
+ return;
+ }
+ if (MY_TEST_CONST !== true && MY_TEST_CONST !== false) {
+ return;
+ }
+ \PHPUnit\Framework\Assert::assertSame(MY_TEST_CONST, 'foo');
+ }
+}
+
+const PHPSTAN_PHPUNIT_TRUE = true;
+const PHPSTAN_PHPUNIT_FALSE = false;
diff --git a/tests/Rules/PHPUnit/data/assert-same-count.php b/tests/Rules/PHPUnit/data/assert-same-count.php
new file mode 100644
index 00000000..bc40eed7
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/assert-same-count.php
@@ -0,0 +1,64 @@
+assertSame(5, count([1, 2, 3]));
+ }
+
+ public function testAssertSameWithCountMethodIsOK()
+ {
+ $foo = new \stdClass();
+
+ $this->assertSame(5, $foo->count()); // OK
+ }
+
+ public function testAssertSameIsDetectedWithDirectAssertAccess()
+ {
+ \PHPUnit\Framework\Assert::assertSame(5, count([1, 2, 3]));
+ }
+
+ public function testAssertSameWithCountMethodForCountableVariableIsNotOK()
+ {
+ $foo = new \stdClass();
+ $foo->bar = new Bar ();
+
+ $this->assertSame(5, $foo->bar->count());
+ }
+
+ public function testRecursiveCount($x)
+ {
+ $this->assertSame(5, count([1, 2, 3, $x], COUNT_RECURSIVE)); // OK
+ }
+
+ public function testNormalCount($x)
+ {
+ $this->assertSame(5, count([1, 2, 3, $x], COUNT_NORMAL));
+ }
+
+ public function testImplicitNormalCount($mode)
+ {
+ $this->assertSame(5, count([1, 2, 3], $mode));
+ }
+
+ public function testUnknownCountable($x, $mode)
+ {
+ $this->assertSame(5, count($x, $mode)); // OK
+ }
+
+ public function testUnknownCountMode($x, $mode)
+ {
+ $this->assertSame(5, count([1, 2, 3, $x], $mode)); // OK
+ }
+}
+
+class Bar implements \Countable {
+ public function count(): int
+ {
+ return 1;
+ }
+}
diff --git a/tests/Rules/PHPUnit/data/assert-same-null-expected-fixable.php b/tests/Rules/PHPUnit/data/assert-same-null-expected-fixable.php
new file mode 100644
index 00000000..f95fadd6
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/assert-same-null-expected-fixable.php
@@ -0,0 +1,22 @@
+assertSame(null, 'a');
+
+ \PHPUnit\Framework\Assert::assertSame($this->returnNull(), 'foo');
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/data/assert-same-null-expected-fixable.php.fixed b/tests/Rules/PHPUnit/data/assert-same-null-expected-fixable.php.fixed
new file mode 100644
index 00000000..a3c91042
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/assert-same-null-expected-fixable.php.fixed
@@ -0,0 +1,22 @@
+assertNull('a');
+
+ \PHPUnit\Framework\Assert::assertSame($this->returnNull(), 'foo');
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/data/assert-same-null-expected.php b/tests/Rules/PHPUnit/data/assert-same-null-expected.php
new file mode 100644
index 00000000..fedc4c98
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/assert-same-null-expected.php
@@ -0,0 +1,65 @@
+assertSame(null, 'a');
+
+ $a = null;
+ $this->assertSame($a, 'b'); // using variable is OK
+
+ $this->assertSame('a', 'b'); // OK
+
+ /** @var string|null $c */
+ $c = null;
+ $this->assertSame($c, 'foo'); // nullable is OK
+ }
+
+ public function testAssertSameIsDetectedWithDirectAssertAccess()
+ {
+ \PHPUnit\Framework\Assert::assertSame(null, 'foo');
+ }
+
+ public function testConstant(): void
+ {
+ \PHPUnit\Framework\Assert::assertSame(PHPSTAN_PHPUNIT_NULL, 'foo');
+ }
+
+ private const NULL = null;
+
+ public function testClassConstant(): void
+ {
+ \PHPUnit\Framework\Assert::assertSame(self::NULL, 'foo');
+ }
+
+ public function returnNullable(): ?string
+ {
+
+ }
+
+ /**
+ * @return null
+ */
+ public function returnNull()
+ {
+ return null;
+ }
+
+ public function testMethodCalls(): void
+ {
+ \PHPUnit\Framework\Assert::assertSame($this->returnNull(), 'foo');
+ \PHPUnit\Framework\Assert::assertSame($this->returnNullable(), 'foo');
+ }
+
+ public function testNonLowercase(): void
+ {
+ \PHPUnit\Framework\Assert::assertSame(Null, 'foo');
+ }
+
+}
+
+const PHPSTAN_PHPUNIT_NULL = null;
diff --git a/tests/Rules/PHPUnit/data/assert-same.php b/tests/Rules/PHPUnit/data/assert-same.php
new file mode 100644
index 00000000..8ab297bd
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/assert-same.php
@@ -0,0 +1,89 @@
+assertSame('1', 1);
+ $this->assertSame('1', new \stdClass());
+ $this->assertSame(1, $this->returnsString());
+ $this->assertSame('1', self::returnsInt());
+ $this->assertSame(['a', 'b'], [1, 2]);
+
+ self::assertSame('1', 2); // test self
+ static::assertSame('1', 2); // test static
+ parent::assertSame('1', 2); // test parent
+ }
+
+ private function returnsString(): string
+ {
+ return 'foo';
+ }
+
+ private static function returnsInt(): int
+ {
+ return 1;
+ }
+
+ public function testArrays()
+ {
+ /** @var string[] $a */
+ $a = ['x'];
+
+ /** @var int[] $b */
+ $b = [1, 2];
+
+ $this->assertSame($a, $b);
+ }
+
+ public function testLogicallyCorrectAssertSame()
+ {
+ $this->assertSame(1, 1);
+ $this->assertSame(['a'], ['a', 'b']);
+ $this->assertSame('1', '1');
+ $this->assertSame('1', '2');
+ $this->assertSame(new \stdClass(), new \stdClass());
+ $this->assertSame('1', $this->returnsString());
+ $this->assertSame(1, self::returnsInt());
+ $this->assertSame(['a'], ['a', 1]);
+ $this->assertSame(['a', 2, 3.0], ['a', 1]);
+ self::assertSame(1, 2); // test self
+ static::assertSame(1, 2); // test static
+ parent::assertSame(1, 2); // test parent
+ }
+
+ public function testOther()
+ {
+ // assertEquals is not checked
+ $this->assertEquals('1', 1);
+
+ // only calls on \PHPUnit\Framework\TestCase are analyzed
+ $foo = new \Dummy\Foo();
+ $foo->assertSame();
+ }
+
+ public function testAssertContains()
+ {
+ $this->assertContains('not in the list', new \ArrayObject([1]));
+ $this->assertContainsEquals('not in the list', new \ArrayObject([1]));
+ $this->assertNotContains('not in the list', new \ArrayObject([1]));
+ }
+
+ public function testStaticMethodReturnWithSameTypeIsNotReported()
+ {
+ $this->assertSame(self::createSomething('foo'), self::createSomething('foo'));
+ $this->assertNotSame(self::createSomething('bar'), self::createSomething('bar'));
+ }
+
+ /**
+ * @return object
+ */
+ private static function createSomething(string $what)
+ {
+ return new \stdClass();
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/data/bug-141.php b/tests/Rules/PHPUnit/data/bug-141.php
new file mode 100644
index 00000000..30011049
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/bug-141.php
@@ -0,0 +1,53 @@
+ $a
+ */
+ public function doFoo(array $a): void
+ {
+ $this->assertEmpty($a);
+ }
+
+ /**
+ * @param non-empty-array<'0.6.0'|'1.0.0'|'1.0.x-dev'|'1.1.x-dev'|'9999999-dev'|'dev-feature-b', true> $a
+ */
+ public function doBar(array $a): void
+ {
+ $this->assertEmpty($a);
+ }
+
+ public function doBaz(): void
+ {
+ $expected = [
+ '0.6.0' => true,
+ '1.0.0' => true,
+ '1.0.x-dev' => true,
+ '1.1.x-dev' => true,
+ 'dev-feature-b' => true,
+ 'dev-feature/a-1.0-B' => true,
+ 'dev-master' => true,
+ '9999999-dev' => true, // alias of dev-master
+ ];
+
+ /** @var array */
+ $packages = ['0.6.0', '1.0.0', '1'];
+
+ foreach ($packages as $version) {
+ if (isset($expected[$version])) {
+ unset($expected[$version]);
+ } else {
+ throw new \Exception('Unexpected version '.$version);
+ }
+ }
+
+ $this->assertEmpty($expected);
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/data/bug-227.php b/tests/Rules/PHPUnit/data/bug-227.php
new file mode 100644
index 00000000..1efe6c1c
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/bug-227.php
@@ -0,0 +1,42 @@
+= 8.0
+
+namespace Bug227;
+
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use stdClass;
+
+class Foo
+{
+
+ public function addCacheTags(array $tags)
+ {
+
+ }
+
+ public function getLanguage(): stdClass
+ {
+
+ }
+
+}
+
+class SomeTest extends TestCase
+{
+
+ protected MockObject|Foo $tsfe;
+
+ protected function setUp(): void
+ {
+ $this->tsfe = $this->getMockBuilder(Foo::class)
+ ->onlyMethods(['addCacheTags', 'getLanguage'])
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->tsfe->method('getLanguage')->willReturn('aaa');
+ }
+
+ public function testSometest(): void
+ {
+ $this->tsfe->expects(self::once())->method('addCacheTags');
+ }
+}
diff --git a/tests/Rules/PHPUnit/data/class-coverage.php b/tests/Rules/PHPUnit/data/class-coverage.php
new file mode 100644
index 00000000..c231f772
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/class-coverage.php
@@ -0,0 +1,66 @@
+ 'Hello World',
+ "expectedResult" => " Hello World \n"
+ ]
+ ];
+
+ if (rand(0,1)) {
+ $arr = [
+ [
+ "input" => 123,
+ "expectedResult" => " Hello World \n"
+ ]
+ ];
+ }
+ if (rand(0,1)) {
+ $arr = [
+ [
+ "input" => false,
+ "expectedResult" => " Hello World \n"
+ ]
+ ];
+ }
+
+ return $arr;
+ }
+}
+
+
+class TestWrongOffsetNameArrayShapeIterable extends TestCase
+{
+ /** @dataProvider aProvider */
+ public function testBar(int $si): void
+ {
+ }
+
+ public function aProvider(): iterable
+ {
+ return $this->data();
+ }
+
+ /**
+ * @return iterable
+ */
+ public function data(): iterable
+ {
+ }
+}
+
+
+class TestWrongTypeInArrayShapeIterable extends TestCase
+{
+ /** @dataProvider aProvider */
+ public function testBar(int $si): void
+ {
+ }
+
+ public function aProvider(): iterable
+ {
+ return $this->data();
+ }
+
+ /**
+ * @return iterable
+ */
+ public function data(): iterable
+ {
+ }
+}
+
+
+class TestValidArrayShapeIterable extends TestCase
+{
+ /** @dataProvider aProvider */
+ public function testBar(int $si): void
+ {
+ }
+
+ public function aProvider(): iterable
+ {
+ return $this->data();
+ }
+
+ /**
+ * @return iterable
+ */
+ public function data(): iterable
+ {
+ }
+}
diff --git a/tests/Rules/PHPUnit/data/data-provider-data.php b/tests/Rules/PHPUnit/data/data-provider-data.php
new file mode 100644
index 00000000..d684df79
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/data-provider-data.php
@@ -0,0 +1,519 @@
+moreData();
+
+ yield [
+ 'Hello World',
+ true,
+ ];
+ }
+
+ /**
+ * @return array{array{'Hello World', 123}}
+ */
+ private function moreData(): array
+ {
+ return [
+ [
+ 'Hello World',
+ 123,
+ ]
+ ];
+ }
+}
+
+class TestValidVariadic extends TestCase
+{
+ /** @dataProvider aProvider */
+ public function testBar(string $s): void
+ {
+ }
+
+ /** @dataProvider aProvider */
+ public function testFoo(string $s, string ...$moreS): void
+ {
+ }
+
+ public function aProvider(): iterable
+ {
+ return [
+ ["hello", "world", "foo", "bar"],
+ ["hi", "ho"],
+ ["nope"]
+ ];
+ }
+}
+
+class TestInvalidVariadic extends TestCase
+{
+ /** @dataProvider aProvider */
+ public function testBar(int $si): void
+ {
+ }
+
+ /** @dataProvider aProvider */
+ public function testFoo(string $s, string ...$moreS): void
+ {
+ }
+
+ public function aProvider(): iterable
+ {
+ return [
+ ["hello", "world", "foo", "bar"],
+ [123]
+ ];
+ }
+}
+
+
+class TestInvalidVariadic2 extends TestCase
+{
+ /** @dataProvider aProvider */
+ public function testBar(int $si): void
+ {
+ }
+
+ /** @dataProvider aProvider */
+ public function testFoo(string $s, int ...$moreS): void
+ {
+ }
+
+ public function aProvider(): iterable
+ {
+ return [
+ ["hello", "world", 5, "bar"],
+ [123]
+ ];
+ }
+}
+
+class TestTypedIterable extends TestCase
+{
+ /** @dataProvider aProvider */
+ public function testBar(int $si): void
+ {
+ }
+
+ public function aProvider(): iterable
+ {
+ return $this->data();
+ }
+
+ /**
+ * @return iterable>
+ */
+ public function data(): iterable
+ {
+ }
+}
+
+class TestArrayIterator extends TestCase
+{
+ /** @dataProvider aProvider */
+ public function testBar(int $i): void
+ {
+ }
+
+ /** @dataProvider aProvider */
+ public function testFoo(int $i, string $si): void
+ {
+ }
+
+ /** @dataProvider aProvider */
+ public function testFooBar(string $s1, string $s2): void
+ {
+ }
+
+ public function aProvider(): iterable
+ {
+ return new \ArrayIterator([
+ [1],
+ [2, "hello"],
+ ["no"],
+ ["no", "yes"],
+ ]);
+ }
+}
+
+class TestWrongTypedIterable extends TestCase
+{
+ /** @dataProvider aProvider */
+ public function testBar(int $si): void
+ {
+ }
+
+ public function aProvider(): iterable
+ {
+ return $this->data();
+ }
+
+ /**
+ * @return iterable>
+ */
+ public function data(): iterable
+ {
+ }
+}
+
+
+abstract class AbstractBaseTest extends TestCase
+{
+
+ #[DataProvider('aProvider')]
+ public function testWithAttribute(string $expectedResult, string $input): void
+ {
+ }
+
+ static public function aProvider(): array
+ {
+ return [
+ [
+ 'Hello World',
+ " Hello World \n",
+ ],
+ [
+ 'Hello World',
+ 123,
+ ],
+ [
+ 'Hello World',
+ false,
+ ],
+ ];
+ }
+}
+
+
+class ConstantArrayUnionTypeReturnTest extends TestCase
+{
+
+ /** @dataProvider aProvider */
+ public function testFoo(string $expectedResult, string $input): void
+ {
+ }
+
+ public function aProvider(): array
+ {
+ if (rand(0,1)) {
+ $arr = [
+ [
+ 'Hello World',
+ 123
+ ]
+ ];
+ } else {
+ $arr = [
+ [
+ 'Hello World',
+ " Hello World \n"
+ ]
+ ];
+ }
+
+ return $arr;
+ }
+}
+
+class ConstantArrayDifferentLengthUnionTypeReturnTest extends TestCase
+{
+
+ /** @dataProvider aProvider */
+ public function testFoo(string $expectedResult, string $input): void
+ {
+ }
+
+ public function aProvider(): array
+ {
+ if (rand(0,1)) {
+ $arr = [
+ [
+ 'Hello World',
+ 123
+ ]
+ ];
+ } elseif (rand(0,1)) {
+ $arr = [
+ [
+ 'Hello World',
+ 'Hello World',
+ ]
+ ];
+ } else {
+ $arr = [
+ [
+ 'Hello World',
+ " Hello World \n",
+ " Too much \n",
+ ]
+ ];
+ }
+
+ return $arr;
+ }
+}
+
+class ConstantArrayUnionWithDifferentValueTypeReturnTest extends TestCase
+{
+
+ /** @dataProvider aProvider */
+ public function testFoo(string $expectedResult, string $input): void
+ {
+ }
+
+ public function aProvider(): array
+ {
+ if (rand(0,1)) {
+ $arr = [
+ [
+ 'Hellooo',
+ ' World',
+ ]
+ ];
+ } else {
+ $a = rand(0,1) ? 'Hello' : 'World';
+ $b = rand(0,1) ? " Hello World \n" : 123;
+
+ $arr = [
+ [
+ $a,
+ $b
+ ]
+ ];
+ }
+
+ return $arr;
+ }
+}
diff --git a/tests/Rules/PHPUnit/data/data-provider-declaration.php b/tests/Rules/PHPUnit/data/data-provider-declaration.php
new file mode 100644
index 00000000..176be2dc
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/data-provider-declaration.php
@@ -0,0 +1,93 @@
+assertTrue(true);
+ }
+
+ public static function dataProvider(): iterable
+ {
+ yield 'even' => [
+ 'int' => 50,
+ 'string' => 'abc',
+ ];
+
+ yield 'odd' => [
+ 'string' => 'def',
+ 'int' => 51,
+ ];
+ }
+}
+
diff --git a/tests/Rules/PHPUnit/data/data-provider-static-fix.php b/tests/Rules/PHPUnit/data/data-provider-static-fix.php
new file mode 100644
index 00000000..1f8005a2
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/data-provider-static-fix.php
@@ -0,0 +1,21 @@
+>
+ */
+ public function getData(): array
+ {
+ return [];
+ }
+
+ public function dataProvide(): array
+ {
+ return $this->getData();
+ }
+
+ /**
+ * @dataProvider dataProvide
+ */
+ public function testProvide(string ...$arg): void
+ {
+
+ }
+
+ /**
+ * @dataProvider dataProvide
+ */
+ public function testProvide2(string $arg): void
+ {
+
+ }
+
+}
+
+class BazTest extends TestCase
+{
+
+ /**
+ * @dataProvider dataProvide
+ */
+ public function testProvide(int $i, int $j, int $k, int ...$m): void
+ {
+
+ }
+
+ /**
+ * @dataProvider dataProvide
+ */
+ public function testProvide2(int $i, int $j, int $k, int $m, int $n): void
+ {
+
+ }
+
+ public function dataProvide(): array
+ {
+ return [
+ [1, 2, 3, 4, 5, 'foo'],
+ ];
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/data/data-provider-variadic-method.php b/tests/Rules/PHPUnit/data/data-provider-variadic-method.php
new file mode 100644
index 00000000..978d197b
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/data-provider-variadic-method.php
@@ -0,0 +1,61 @@
+assertEmpty($c);
+ $this->assertEmpty([]);
+ $this->assertEmpty([1, 2, 3]);
+ }
+
+ public function doBar(object $o): void
+ {
+ $this->assertEmpty($o);
+ }
+
+ /**
+ * @param class-string<\Exception> $name
+ * @return void
+ */
+ public function doBaz(\Exception $e, string $name): void
+ {
+ $this->assertInstanceOf($name, $e);
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/data/method-coverage.php b/tests/Rules/PHPUnit/data/method-coverage.php
new file mode 100644
index 00000000..77ed1ab8
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/method-coverage.php
@@ -0,0 +1,87 @@
+foo = true;
+ }
+}
+
+class BaseTestCase extends TestCase
+{
+ public function setUp(): void
+ {
+ $this->bar = true;
+ }
+
+ public function tearDown(): void
+ {
+ $this->bar = null;
+ }
+}
+
+class BazTest extends BaseTestCase
+{
+ private $baz;
+
+ public function setUp(): void
+ {
+ $this->baz = true;
+ }
+
+ public function baz(): bool
+ {
+ return $this->baz;
+ }
+}
+
+class BarBazTest extends BaseTestCase
+{
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->barBaz = true;
+ }
+}
+
+class FooBarBazTest extends BaseTestCase
+{
+ public function setUp(): void
+ {
+ $result = 1 + 1;
+ parent::tearDown();
+
+ $this->fooBarBaz = $result;
+ }
+
+ public function tearDown(): void
+ {
+ $this->fooBarBaz = null;
+ }
+}
+
+class NormalBaseClass {}
+
+class NormalClass extends NormalBaseClass
+{
+ public function setUp()
+ {
+ return true;
+ }
+}
+
+abstract class BaseTestWithoutSetUp extends TestCase
+{
+
+}
+
+class LoremTest extends BaseTestWithoutSetUp
+{
+
+ protected function setUp(): void
+ {
+ // parent call is not missing here
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/data/mock-method-call.php b/tests/Rules/PHPUnit/data/mock-method-call.php
new file mode 100644
index 00000000..a4f5aaae
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/mock-method-call.php
@@ -0,0 +1,73 @@
+createMock(Bar::class)->method('doThing');
+ }
+
+ public function testBadMethod()
+ {
+ $this->createMock(Bar::class)->method('doBadThing');
+ }
+
+ public function testBadMethodWithExpectation()
+ {
+ $this->createMock(Bar::class)->expects($this->once())->method('doBadThing');
+ }
+
+ public function testWithAnotherObject()
+ {
+ $bar = new BarWithMethod();
+ $bar->method('doBadThing');
+ }
+
+ public function testGoodMethodOnStub()
+ {
+ $this->createStub(Bar::class)->method('doThing');
+ }
+
+ public function testBadMethodOnStub()
+ {
+ $this->createStub(Bar::class)->method('doBadThing');
+ }
+
+ public function testMockObject(\PHPUnit\Framework\MockObject\MockObject $mock)
+ {
+ $mock->method('doFoo');
+ }
+
+}
+
+class Bar {
+ public function doThing()
+ {
+ return 1;
+ }
+};
+
+class BarWithMethod {
+ public function method(string $string)
+ {
+ return $string;
+ }
+};
+
+final class FinalFoo
+{
+
+}
+
+class FinalFooTest extends \PHPUnit\Framework\TestCase
+{
+
+ public function testMockFinalClass()
+ {
+ $this->createMock(FinalFoo::class)->method('doFoo');
+ }
+
+}
diff --git a/tests/Rules/PHPUnit/data/requires-php-version.php b/tests/Rules/PHPUnit/data/requires-php-version.php
new file mode 100644
index 00000000..5550edff
--- /dev/null
+++ b/tests/Rules/PHPUnit/data/requires-php-version.php
@@ -0,0 +1,24 @@
+=8.0')]
+ public function testHappyPath(): void {
+
+ }
+}
diff --git a/tests/Type/PHPUnit/AssertFunctionTypeSpecifyingExtensionTest.php b/tests/Type/PHPUnit/AssertFunctionTypeSpecifyingExtensionTest.php
new file mode 100644
index 00000000..40140174
--- /dev/null
+++ b/tests/Type/PHPUnit/AssertFunctionTypeSpecifyingExtensionTest.php
@@ -0,0 +1,45 @@
+assertFileAsserts($assertType, $file, ...$args);
+ }
+
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [__DIR__ . '/../../../extension.neon'];
+ }
+
+}
diff --git a/tests/Type/PHPUnit/AssertMethodTypeSpecifyingExtensionTest.php b/tests/Type/PHPUnit/AssertMethodTypeSpecifyingExtensionTest.php
new file mode 100644
index 00000000..8c6ebb8b
--- /dev/null
+++ b/tests/Type/PHPUnit/AssertMethodTypeSpecifyingExtensionTest.php
@@ -0,0 +1,36 @@
+assertFileAsserts($assertType, $file, ...$args);
+ }
+
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [__DIR__ . '/../../../extension.neon'];
+ }
+
+}
diff --git a/tests/Type/PHPUnit/DataProviderReturnTypeIgnoreExtensionTest.php b/tests/Type/PHPUnit/DataProviderReturnTypeIgnoreExtensionTest.php
new file mode 100644
index 00000000..fb5b927a
--- /dev/null
+++ b/tests/Type/PHPUnit/DataProviderReturnTypeIgnoreExtensionTest.php
@@ -0,0 +1,38 @@
+
+ */
+class DataProviderReturnTypeIgnoreExtensionTest extends RuleTestCase {
+ protected function getRule(): Rule
+ {
+ /** @phpstan-ignore phpstanApi.classConstant */
+ $rule = self::getContainer()->getByType(MissingMethodReturnTypehintRule::class);
+
+ return $rule;
+ }
+
+ public function testRule(): void
+ {
+ $this->analyse([__DIR__ . '/data/data-provider-iterable-value.php'], [
+ [
+ 'Method DataProviderIterableValueTest\Foo::notADataProvider() return type has no value type specified in iterable type iterable.',
+ 32,
+ 'See: https://phpstan.org/blog/solving-phpstan-no-value-type-specified-in-iterable-type'
+ ],
+ ]);
+ }
+
+ static public function getAdditionalConfigFiles(): array
+ {
+ return [
+ __DIR__ . '/data/data-provider-iterable-value.neon'
+ ];
+ }
+}
diff --git a/tests/Type/PHPUnit/data/assert-function-9.6.11.php b/tests/Type/PHPUnit/data/assert-function-9.6.11.php
new file mode 100644
index 00000000..dea6b851
--- /dev/null
+++ b/tests/Type/PHPUnit/data/assert-function-9.6.11.php
@@ -0,0 +1,17 @@
+ $class
+ */
+ public function assertInstanceOfWorksWithTemplate($o, $class): void
+ {
+ assertInstanceOf($class, $o);
+ assertType(\DateTimeInterface::class, $o);
+ }
+
+ public function arrayHasNumericKey(array $a, \ArrayAccess $b): void {
+ assertArrayHasKey(0, $a);
+ assertType('non-empty-array&hasOffset(0)', $a);
+
+ assertArrayHasKey(0, $b);
+ assertType('ArrayAccess', $b);
+ }
+
+ public function arrayHasStringKey(array $a, \ArrayAccess $b): void
+ {
+ assertArrayHasKey('key', $a);
+ assertType("non-empty-array&hasOffset('key')", $a);
+
+ assertArrayHasKey('key', $b);
+ assertType("ArrayAccess", $b);
+ }
+
+ public function arrayHasExprKey(int $index, array $a): void
+ {
+ assertArrayHasKey($index, $a);
+ assertType("non-empty-array", $a);
+ }
+
+ public function testEmpty($a): void
+ {
+ assertEmpty($a);
+ assertType("0|0.0|''|'0'|array{}|Countable|EmptyIterator|false|null", $a);
+ }
+
+ public function contains(array $a, \Traversable $b): void
+ {
+ assertContains('foo', $a);
+ assertType('non-empty-array', $a);
+
+ assertContains('foo', $b);
+ assertType('Traversable', $b);
+ }
+
+ public function containsEquals(array $a, \Traversable $b): void
+ {
+ assertContainsEquals('foo', $a);
+ assertType('non-empty-array', $a);
+
+ assertContainsEquals('foo', $b);
+ assertType('Traversable', $b);
+ }
+
+ public function containsOnlyInstancesOf(array $a, \Traversable $b): void
+ {
+ assertContainsOnlyInstancesOf(\stdClass::class, $a);
+ assertType('array', $a);
+
+ assertContainsOnlyInstancesOf(\stdClass::class, $b);
+ assertType('Traversable', $b);
+ }
+
+ public function count(array $a, \Countable $b): void
+ {
+ assertCount(3, $a);
+ assertType('non-empty-array', $a);
+
+ assertCount(7, $b);
+ assertType('Countable', $b);
+ }
+
+ public function notCount(array $a, array $b): void
+ {
+ assertNotCount(0, $a);
+ assertType('non-empty-array', $a);
+
+ // still might be empty
+ assertNotCount(1, $b);
+ assertType('array', $b);
+ }
+
+}
diff --git a/tests/Type/PHPUnit/data/assert-method.php b/tests/Type/PHPUnit/data/assert-method.php
new file mode 100644
index 00000000..2f791ed7
--- /dev/null
+++ b/tests/Type/PHPUnit/data/assert-method.php
@@ -0,0 +1,17 @@
+assertNotNull($s);
+ assertType('string', $s);
+ }
+
+}
diff --git a/tests/Type/PHPUnit/data/data-provider-iterable-value.neon b/tests/Type/PHPUnit/data/data-provider-iterable-value.neon
new file mode 100644
index 00000000..e5597bc2
--- /dev/null
+++ b/tests/Type/PHPUnit/data/data-provider-iterable-value.neon
@@ -0,0 +1,6 @@
+parameters:
+ featureToggles:
+ bleedingEdge: true
+
+includes:
+ - ../../../../extension.neon
diff --git a/tests/Type/PHPUnit/data/data-provider-iterable-value.php b/tests/Type/PHPUnit/data/data-provider-iterable-value.php
new file mode 100644
index 00000000..613d3b14
--- /dev/null
+++ b/tests/Type/PHPUnit/data/data-provider-iterable-value.php
@@ -0,0 +1,39 @@
+
-
-
- ../src
-
-
-
-
-
-
-
diff --git a/tmp/.gitignore b/tmp/.gitignore
new file mode 100644
index 00000000..37890cae
--- /dev/null
+++ b/tmp/.gitignore
@@ -0,0 +1,3 @@
+*
+!cache
+!.*
diff --git a/tmp/cache/.gitignore b/tmp/cache/.gitignore
new file mode 100644
index 00000000..125e3429
--- /dev/null
+++ b/tmp/cache/.gitignore
@@ -0,0 +1,2 @@
+*
+!.*