diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 44eb499..02cd580 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -60,3 +60,43 @@ jobs: - uses: ramsey/composer-install@a8d0d959dab41457692a5e2041bd9b757a119e3f # 3.2.1 - run: composer test + + coverage: + name: Coverage + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + services: + mariadb: + image: mariadb:11 + env: + MARIADB_ROOT_PASSWORD: root + MARIADB_DATABASE: wordpress_test + ports: + - 3306:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=5s + --health-timeout=5s + --health-retries=10 + env: + WP_TESTS_DB_HOST: 127.0.0.1 + WP_TESTS_DB_NAME: wordpress_test + WP_TESTS_DB_USER: root + WP_TESTS_DB_PASSWORD: root + steps: + - uses: actions/checkout@v5 + + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.3' + extensions: mysqli + coverage: xdebug + tools: composer + + - uses: ramsey/composer-install@a8d0d959dab41457692a5e2041bd9b757a119e3f # 3.2.1 + + - run: composer coverage + + - uses: k1LoW/octocov-action@a167dc0dee441b7ffc45e1b862ab55ec0d87f278 # v1.5.2 diff --git a/.gitignore b/.gitignore index 9851b06..10fdae2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ languages/*.mo /vendor/ /vendor-bin/*/vendor/ +/tests/.coverage/ /.phpunit.result.cache /node_modules/ /tests/E2E/.results/ diff --git a/.octocov.yml b/.octocov.yml new file mode 100644 index 0000000..dcb8e46 --- /dev/null +++ b/.octocov.yml @@ -0,0 +1,13 @@ +coverage: + paths: + - tests/.coverage/clover.xml + acceptable: current >= 90% && current >= prev + +diff: + datastores: + - artifact://${GITHUB_REPOSITORY} + +report: + if: is_default_branch + datastores: + - artifact://${GITHUB_REPOSITORY} diff --git a/composer.json b/composer.json index 44c3d60..9a29348 100644 --- a/composer.json +++ b/composer.json @@ -65,6 +65,10 @@ ], "test:unit": "phpunit", "test:integration": "phpunit -c phpunit-integration.xml.dist", + "coverage": [ + "@putenv XDEBUG_MODE=coverage", + "phpunit -c phpunit-coverage.xml.dist --coverage-clover=tests/.coverage/clover.xml" + ], "ci": [ "@lint", "@cs", diff --git a/phpunit-coverage.xml.dist b/phpunit-coverage.xml.dist new file mode 100644 index 0000000..75ae9ca --- /dev/null +++ b/phpunit-coverage.xml.dist @@ -0,0 +1,27 @@ + + + + + tests/Unit + + + tests/Integration + + + + + functions.php + inc + src + woocommerce + + + diff --git a/readme.txt b/readme.txt index f8f5ba1..337b45d 100644 --- a/readme.txt +++ b/readme.txt @@ -58,6 +58,14 @@ Every check is a Composer script: - `composer stan`: PHPStan - `composer test`: PHPUnit - `composer ci`: all of the above, in this order +- `composer coverage`: PHPUnit with a coverage report for octocov + +Every PHP file in `inc/`, `src/` and `woocommerce/` needs a test named after it, +at the same path: under `tests/Integration` for `inc/` and `woocommerce/`, which +need WordPress, and under `tests/Unit` or `tests/Integration` for `src/`. A browser +test under `tests/E2E` follows the same names. `tests/Unit/StructureTest.php` +enforces this and also fails on a test whose file no longer exists. In CI, octocov fails the run when line +coverage is below 90% or below the last report of `main` (`.octocov.yml`). `composer install` brings in WordPress, the WordPress test suite and WooCommerce, so the tests only need a MySQL/MariaDB database they are allowed to wipe on every diff --git a/tests/Support/ThemeFiles.php b/tests/Support/ThemeFiles.php new file mode 100644 index 0000000..c7af470 --- /dev/null +++ b/tests/Support/ThemeFiles.php @@ -0,0 +1,35 @@ +isFile() && str_ends_with( $file->getPathname(), $suffix ) ) { + $paths[] = substr( $file->getPathname(), strlen( self::root() ) + 1 ); + } + } + + sort( $paths ); + + return $paths; + } +} diff --git a/tests/Unit/StructureTest.php b/tests/Unit/StructureTest.php new file mode 100644 index 0000000..712c2d8 --- /dev/null +++ b/tests/Unit/StructureTest.php @@ -0,0 +1,103 @@ +assert_one_exists( self::tests_covering( $file ), $file . ' is not covered by' ); + } + + public static function provide_theme_files() { + $files = array_merge( + ThemeFiles::under( 'inc', '.php' ), + ThemeFiles::under( 'src', '.php' ), + ThemeFiles::under( 'woocommerce', '.php' ) + ); + + foreach ( $files as $file ) { + yield $file => array( $file ); + } + } + + /** + * @dataProvider provide_test_files + */ + public function test_a_test_covers_a_file_of_the_theme( $file ) { + $this->assert_one_exists( self::files_covered_by( $file ), $file . ' does not cover' ); + } + + public static function provide_test_files() { + $files = array_merge( + ThemeFiles::under( 'tests/Unit', 'Test.php' ), + ThemeFiles::under( 'tests/Integration', 'Test.php' ), + ThemeFiles::under( 'tests/E2E', '.spec.ts' ) + ); + + foreach ( $files as $file ) { + if ( 'tests/Unit/StructureTest.php' !== $file ) { + yield $file => array( $file ); + } + } + } + + private static function tests_covering( $file ) { + if ( str_starts_with( $file, 'src/' ) ) { + $name = substr( $file, strlen( 'src/' ), -strlen( '.php' ) ); + + return array( + 'tests/Unit/' . $name . 'Test.php', + 'tests/Integration/' . $name . 'Test.php', + ); + } + + return array( 'tests/Integration/' . self::test_path( substr( $file, 0, -strlen( '.php' ) ) ) . 'Test.php' ); + } + + private static function files_covered_by( $file ) { + if ( str_starts_with( $file, 'tests/E2E/' ) ) { + $name = substr( $file, strlen( 'tests/E2E/' ), -strlen( '.spec.ts' ) ); + + return array( + 'src/' . $name . '.php', + self::theme_path( $name ) . '.php', + ); + } + + if ( str_starts_with( $file, 'tests/Unit/' ) ) { + return array( 'src/' . substr( $file, strlen( 'tests/Unit/' ), -strlen( 'Test.php' ) ) . '.php' ); + } + + $name = substr( $file, strlen( 'tests/Integration/' ), -strlen( 'Test.php' ) ); + + return array( + 'src/' . $name . '.php', + self::theme_path( $name ) . '.php', + ); + } + + private function assert_one_exists( array $files, $subject ) { + $found = array_filter( + $files, + static function ( $file ) { + return file_exists( ThemeFiles::root() . '/' . $file ); + } + ); + + $this->assertNotEmpty( $found, sprintf( '%s %s.', $subject, implode( ' or ', $files ) ) ); + } + + private static function test_path( $theme_path ) { + return implode( '/', array_map( static fn ( $part ) => str_replace( ' ', '', ucwords( str_replace( '-', ' ', $part ) ) ), explode( '/', $theme_path ) ) ); + } + + private static function theme_path( $test_path ) { + return implode( '/', array_map( static fn ( $part ) => strtolower( (string) preg_replace( '/(?