diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..5397f59 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,38 @@ +name: Tests + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + + name: PHP ${{ matrix.php }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip + coverage: none + + - name: Install xmlstarlet + run: sudo apt-get update && sudo apt-get install -y xmlstarlet + + - name: Install dependencies + run: composer update --prefer-dist --no-progress + + - name: Run test suite + run: ./vendor/bin/phpunit --testdox diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml new file mode 100644 index 0000000..277821c --- /dev/null +++ b/.github/workflows/update-changelog.yml @@ -0,0 +1,32 @@ +name: "Update Changelog" + +on: + release: + types: [released] + +permissions: + contents: write + +jobs: + update: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + ref: main + + - name: Update Changelog + uses: stefanzweifel/changelog-updater-action@v1 + with: + latest-version: ${{ github.event.release.name }} + release-notes: ${{ github.event.release.body }} + + - name: Commit updated CHANGELOG + uses: stefanzweifel/git-auto-commit-action@v7 + with: + branch: main + commit_message: Update CHANGELOG + file_pattern: CHANGELOG.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dddbc6a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +vendor +.phpunit.cache +composer.lock diff --git a/README.md b/README.md index 3635a44..ba6b668 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,16 @@ For XmlStarlet on other platforms, see [here](http://xmlstar.sourceforge.net/doc ./vendor/bin/phpunit-failed-runner ``` +## Testing + +This package includes a comprehensive test suite that demonstrates the incremental test-fixing workflow. + +Run the test suite: + +```bash +composer test +``` + ### Changelog Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. diff --git a/bin/phpunit-failed-runner b/bin/phpunit-failed-runner index 564a529..7bc4eb4 100755 --- a/bin/phpunit-failed-runner +++ b/bin/phpunit-failed-runner @@ -8,10 +8,22 @@ else runner="./vendor/bin/phpunit" fi +# Determine XSL path - check if we're in the package itself or using it as a dependency +if [ -f "./prune.xsl" ]; then + # We're in the package root + xsl_path="." +elif [ -f "./vendor/chrisdicarlo/phpunit-failed-runner/prune.xsl" ]; then + # We're using the package as a dependency + xsl_path="./vendor/chrisdicarlo/phpunit-failed-runner" +else + echo "Error: Cannot find XSL transformation files" + exit 1 +fi + if test -f "$logfile"; then echo -e "Logfile found. Searching for previously failing tests... \U23F3" - count_failed_tests="$(xmlstarlet tr ./vendor/chrisdicarlo/phpunit-failed-runner/prune.xsl junit.xml | xmlstarlet tr --omit-decl ./vendor/chrisdicarlo/phpunit-failed-runner/count-failed.xsl)" + count_failed_tests="$(xmlstarlet tr "$xsl_path/prune.xsl" junit.xml | xmlstarlet tr --omit-decl "$xsl_path/count-failed.xsl")" if [ "$count_failed_tests" = "0" ]; then echo -e "No failed tests! Great job! \U1F44D \U1F389" @@ -19,7 +31,7 @@ if test -f "$logfile"; then exit 0 else echo -e "Found $count_failed_tests previously failing tests, filtering... \U1F97A" - filter=$(xmlstarlet tr ./vendor/chrisdicarlo/phpunit-failed-runner/prune.xsl junit.xml | xmlstarlet tr --omit-decl ./vendor/chrisdicarlo/phpunit-failed-runner/failed-tests.xsl); "$runner" --filter "$filter" --log-junit junit.xml + filter=$(xmlstarlet tr "$xsl_path/prune.xsl" junit.xml | xmlstarlet tr --omit-decl "$xsl_path/failed-tests.xsl"); "$runner" --filter "$filter" --log-junit junit.xml fi else echo -e "Logfile not found. Running the test suite... \U1F91E" @@ -29,7 +41,7 @@ else fi if test -f "$logfile"; then - count_failed_tests="$(xmlstarlet tr ./vendor/chrisdicarlo/phpunit-failed-runner/prune.xsl junit.xml | xmlstarlet tr --omit-decl ./vendor/chrisdicarlo/phpunit-failed-runner/count-failed.xsl)" + count_failed_tests="$(xmlstarlet tr "$xsl_path/prune.xsl" junit.xml | xmlstarlet tr --omit-decl "$xsl_path/count-failed.xsl")" if [ "$count_failed_tests" = "0" ]; then echo -e "No failed tests! Great job! \U1F44D \U1F389" diff --git a/composer.json b/composer.json index dc23a5c..b5986dd 100644 --- a/composer.json +++ b/composer.json @@ -19,5 +19,16 @@ ], "bin": [ "bin/phpunit-failed-runner" - ] + ], + "require-dev": { + "phpunit/phpunit": "^8.5|^9.6|^10.5|^11.0" + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit" + } } diff --git a/phpunit-integration-fixtures.xml b/phpunit-integration-fixtures.xml new file mode 100644 index 0000000..cbd0c63 --- /dev/null +++ b/phpunit-integration-fixtures.xml @@ -0,0 +1,21 @@ + + + + + tests/AlwaysPassingTest.php + tests/InitiallyFailingTest.php + tests/AnotherFailingTest.php + + + + + bin + + + diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..fede57b --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,20 @@ + + + + + tests + tests/Fixtures + + + + + bin + + + diff --git a/tests/AlwaysPassingTest.php b/tests/AlwaysPassingTest.php new file mode 100644 index 0000000..8251285 --- /dev/null +++ b/tests/AlwaysPassingTest.php @@ -0,0 +1,26 @@ +assertTrue( + $fixture->success(), + 'AlwaysPassingFixture should always return true' + ); + } + + public function test_another_always_passing_test(): void + { + $fixture = new AlwaysPassingFixture(); + + $this->assertTrue($fixture->success()); + } +} diff --git a/tests/AnotherFailingTest.php b/tests/AnotherFailingTest.php new file mode 100644 index 0000000..3404e85 --- /dev/null +++ b/tests/AnotherFailingTest.php @@ -0,0 +1,19 @@ +assertTrue( + $fixture->success(), + 'This test will also fail initially' + ); + } +} diff --git a/tests/Fixtures/AlwaysPassingFixture.php b/tests/Fixtures/AlwaysPassingFixture.php new file mode 100644 index 0000000..f24b4c3 --- /dev/null +++ b/tests/Fixtures/AlwaysPassingFixture.php @@ -0,0 +1,14 @@ +assertTrue( + $fixture->success(), + 'This test will fail initially but pass after the fixture is fixed' + ); + } + + public function test_another_initially_failing_test(): void + { + $fixture = new InitiallyFailingFixture(); + + $this->assertTrue( + $fixture->success(), + 'Another test that depends on the same fixture' + ); + } +} diff --git a/tests/Integration/ScriptBehaviorTest.php b/tests/Integration/ScriptBehaviorTest.php new file mode 100644 index 0000000..80c8631 --- /dev/null +++ b/tests/Integration/ScriptBehaviorTest.php @@ -0,0 +1,293 @@ +projectRoot = dirname(__DIR__, 2); + $this->junitPath = $this->projectRoot . '/junit.xml'; + $this->scriptPath = $this->projectRoot . '/bin/phpunit-failed-runner'; + $this->fixtureFilePath = $this->projectRoot . '/tests/Fixtures/InitiallyFailingFixture.php'; + + // Clean up any existing junit.xml before each test + if (file_exists($this->junitPath)) { + unlink($this->junitPath); + } + + // Backup the fixture file + $this->originalFixtureContent = file_get_contents($this->fixtureFilePath); + } + + protected function tearDown(): void + { + // Restore the fixture file + if ($this->originalFixtureContent !== null) { + file_put_contents($this->fixtureFilePath, $this->originalFixtureContent); + } + + // Clean up junit.xml after each test + if (file_exists($this->junitPath)) { + unlink($this->junitPath); + } + } + + /** + * Execute the script and capture output + * Uses phpunit-integration-fixtures.xml to only run fixture tests + */ + private function runScript(?string &$output = null): int + { + $output = []; + $exitCode = 0; + + // Use the fixtures config to only run fixture tests (not the full suite) + exec("cd {$this->projectRoot} && {$this->scriptPath} -c phpunit-integration-fixtures.xml 2>&1", $output, $exitCode); + $output = implode("\n", $output); + + return $exitCode; + } + + /** + * Make the fixture fail by changing return true to return false + */ + private function makeFixtureFail(): void + { + $content = file_get_contents($this->fixtureFilePath); + $content = str_replace('return true;', 'return false;', $content); + file_put_contents($this->fixtureFilePath, $content); + } + + /** + * Make the fixture pass by changing return false to return true + */ + private function makeFixturePass(): void + { + $content = file_get_contents($this->fixtureFilePath); + $content = str_replace('return false;', 'return true;', $content); + file_put_contents($this->fixtureFilePath, $content); + } + + public function test_script_runs_full_suite_when_no_logfile_exists(): void + { + // Arrange: No junit.xml exists, make fixture fail so junit.xml persists + $this->assertFileDoesNotExist($this->junitPath); + $this->makeFixtureFail(); + + // Act: Run the script + $exitCode = $this->runScript($output); + + // Assert: Should run full suite + $this->assertEquals(0, $exitCode, 'Script should exit successfully'); + $this->assertStringContainsString('Logfile not found', $output); + $this->assertStringContainsString('Running the test suite', $output); + + // junit.xml should be created and persist (because test failed) + $this->assertFileExists($this->junitPath); + } + + public function test_script_removes_logfile_when_all_tests_pass(): void + { + // Arrange: Make fixture fail first, run script to create junit.xml + $this->makeFixtureFail(); + $this->runScript($firstOutput); + $this->assertFileExists($this->junitPath, 'junit.xml should exist after failed run'); + + // Now fix the fixture + $this->makeFixturePass(); + + // Act: Run script again (it should find the logfile with no failures) + $exitCode = $this->runScript($output); + + // Assert: Should clean up the logfile + $this->assertEquals(0, $exitCode); + $this->assertStringContainsString('Logfile found', $output); + $this->assertStringContainsString('No failed tests', $output); + $this->assertStringContainsString('Great job', $output); + + // junit.xml should be removed + $this->assertFileDoesNotExist($this->junitPath); + } + + public function test_script_filters_and_reruns_only_failing_tests(): void + { + // Arrange: Make fixture fail, run script to create junit.xml with failures + $this->makeFixtureFail(); + $this->runScript($firstOutput); + + $this->assertFileExists($this->junitPath); + $this->assertStringContainsString('Logfile not found', $firstOutput); + + // Act: Run script again (should detect failures and rerun only those tests) + $exitCode = $this->runScript($secondOutput); + + // Assert: Should find and filter failed tests + $this->assertEquals(0, $exitCode); + $this->assertStringContainsString('Logfile found', $secondOutput); + $this->assertStringContainsString('Searching for previously failing tests', $secondOutput); + $this->assertStringContainsString('previously failing tests, filtering', $secondOutput); + + // junit.xml should still exist (tests still failing) + $this->assertFileExists($this->junitPath); + } + + public function test_full_workflow_fail_then_fix_then_cleanup(): void + { + // Phase 1: Run with failing test + $this->makeFixtureFail(); + $exitCode1 = $this->runScript($output1); + + $this->assertEquals(0, $exitCode1, 'Script should exit successfully even with failures'); + $this->assertStringContainsString('Logfile not found', $output1); + $this->assertFileExists($this->junitPath); + + // Phase 2: Run again (should detect and rerun failures) + $exitCode2 = $this->runScript($output2); + + $this->assertEquals(0, $exitCode2); + $this->assertStringContainsString('Logfile found', $output2); + $this->assertStringContainsString('previously failing tests', $output2); + $this->assertFileExists($this->junitPath); + + // Phase 3: Fix the test and run again + $this->makeFixturePass(); + $exitCode3 = $this->runScript($output3); + + // Assert: After fixing, should clean up + $this->assertEquals(0, $exitCode3); + $this->assertStringContainsString('Logfile found', $output3); + $this->assertStringContainsString('No failed tests', $output3); + $this->assertFileDoesNotExist($this->junitPath, 'junit.xml should be cleaned up after all tests pass'); + } + + public function test_script_counts_failed_tests_correctly(): void + { + // Arrange: Make fixture fail + $this->makeFixtureFail(); + $this->runScript(); + + // Act: Run again and check the count in output + $this->runScript($output); + + // Assert: Output should mention the count of failed tests + $this->assertStringContainsString('Logfile found', $output); + + // The script should report finding failed tests + $this->assertMatchesRegularExpression( + '/Found \d+ previously failing tests/', + $output, + 'Should report count of failed tests' + ); + } + + public function test_script_exits_with_zero_on_success(): void + { + // Arrange: All tests passing + $this->makeFixturePass(); + + // Act: Run script + $exitCode = $this->runScript(); + + // Assert: Should exit with 0 + $this->assertEquals(0, $exitCode, 'Script should always exit with code 0'); + } + + public function test_script_creates_valid_junit_xml(): void + { + // Arrange: Make fixture fail so junit.xml persists + $this->makeFixtureFail(); + + // Act: Run script + $this->runScript(); + + // Assert: junit.xml should be valid XML + $this->assertFileExists($this->junitPath); + + $xml = @simplexml_load_file($this->junitPath); + $this->assertNotFalse($xml, 'junit.xml should be valid XML'); + $this->assertEquals('testsuites', $xml->getName()); + } + + public function test_script_handles_no_failures_on_first_run(): void + { + // Arrange: All tests passing + $this->makeFixturePass(); + + // Act: Run script (first run, no logfile) + $exitCode = $this->runScript($output); + + // Assert: Should run full suite and clean up immediately if all pass + $this->assertEquals(0, $exitCode); + $this->assertStringContainsString('Logfile not found', $output); + + // The logfile might be cleaned up immediately after first run if all tests pass + // Check if we see success message + if (!file_exists($this->junitPath)) { + $this->assertStringContainsString('No failed tests', $output); + } + } + + public function test_script_output_contains_expected_messages(): void + { + // Test that the script outputs the expected emoji/messages + $this->makeFixtureFail(); + $this->runScript($firstOutput); + + // First run: should see "Logfile not found" + $this->assertStringContainsString('Logfile not found', $firstOutput); + $this->assertStringContainsString('Running the test suite', $firstOutput); + + // Second run: should see "Logfile found" + $this->runScript($secondOutput); + $this->assertStringContainsString('Logfile found', $secondOutput); + $this->assertStringContainsString('Searching for previously failing tests', $secondOutput); + } + + public function test_script_persists_junit_xml_when_tests_fail(): void + { + // Arrange: Make fixture fail + $this->makeFixtureFail(); + + // Act: Run script + $this->runScript(); + + // Assert: junit.xml should persist (not be deleted) + $this->assertFileExists($this->junitPath, 'junit.xml should not be deleted when tests fail'); + } + + public function test_script_uses_xmlstarlet_for_transformations(): void + { + // This test verifies xmlstarlet is available (required dependency) + // Act: Run script + $exitCode = $this->runScript($output); + + // Assert: Should not contain errors about xmlstarlet + $this->assertStringNotContainsString('xmlstarlet: command not found', $output); + $this->assertStringNotContainsString('xmlstarlet: not found', $output); + } + + public function test_script_detects_xsl_files_in_project_root(): void + { + // Act: Run script + $exitCode = $this->runScript($output); + + // Assert: Should not contain XSL path detection errors + $this->assertStringNotContainsString('Cannot find XSL transformation files', $output); + $this->assertEquals(0, $exitCode); + } +} diff --git a/tests/Unit/XslTransformationTest.php b/tests/Unit/XslTransformationTest.php new file mode 100644 index 0000000..bfb5da9 --- /dev/null +++ b/tests/Unit/XslTransformationTest.php @@ -0,0 +1,195 @@ +projectRoot = dirname(__DIR__, 1); + } + + public function test_prune_xsl_extracts_failed_tests_from_junit_xml(): void + { + $junitXml = << + + + + + + Failed + + + Failed + + + +XML; + + $tempFile = tempnam(sys_get_temp_dir(), 'junit'); + file_put_contents($tempFile, $junitXml); + + $output = shell_exec("xmlstarlet tr {$this->projectRoot}/../prune.xsl {$tempFile}"); + + $this->assertStringContainsString('', $output); + $this->assertStringContainsString('Tests\FailingTest::test_failing_one', $output); + $this->assertStringContainsString('Tests\AnotherTest::test_failing_two', $output); + $this->assertStringNotContainsString('PassingTest', $output); + + unlink($tempFile); + } + + public function test_prune_xsl_handles_errors_as_failures(): void + { + $junitXml = << + + + + Error occurred + + + Failed + + + +XML; + + $tempFile = tempnam(sys_get_temp_dir(), 'junit'); + file_put_contents($tempFile, $junitXml); + + $output = shell_exec("xmlstarlet tr {$this->projectRoot}/../prune.xsl {$tempFile}"); + + $this->assertStringContainsString('ErrorTest::test_with_error', $output); + $this->assertStringContainsString('FailureTest::test_with_failure', $output); + + unlink($tempFile); + } + + public function test_count_failed_xsl_counts_tests_correctly(): void + { + $prunedXml = << + + Tests\FailingTest::test_one + Tests\FailingTest::test_two + Tests\AnotherTest::test_three + +XML; + + $tempFile = tempnam(sys_get_temp_dir(), 'pruned'); + file_put_contents($tempFile, $prunedXml); + + $count = trim(shell_exec("xmlstarlet tr --omit-decl {$this->projectRoot}/../count-failed.xsl {$tempFile}")); + + $this->assertEquals('3', $count); + + unlink($tempFile); + } + + public function test_count_failed_xsl_returns_zero_for_no_failures(): void + { + $prunedXml = << + + +XML; + + $tempFile = tempnam(sys_get_temp_dir(), 'pruned'); + file_put_contents($tempFile, $prunedXml); + + $count = trim(shell_exec("xmlstarlet tr --omit-decl {$this->projectRoot}/../count-failed.xsl {$tempFile}")); + + $this->assertEquals('0', $count); + + unlink($tempFile); + } + + public function test_failed_tests_xsl_creates_phpunit_filter(): void + { + $prunedXml = << + + Tests\FailingTest::test_one + Tests\FailingTest::test_two + Tests\AnotherTest::test_three + +XML; + + $tempFile = tempnam(sys_get_temp_dir(), 'pruned'); + file_put_contents($tempFile, $prunedXml); + + $filter = trim(shell_exec("xmlstarlet tr --omit-decl {$this->projectRoot}/../failed-tests.xsl {$tempFile}")); + + $this->assertStringContainsString('Tests\\\\FailingTest::test_one', $filter); + $this->assertStringContainsString('Tests\\\\FailingTest::test_two', $filter); + $this->assertStringContainsString('Tests\\\\AnotherTest::test_three', $filter); + $this->assertStringContainsString('|', $filter); // Tests are separated by | + + unlink($tempFile); + } + + public function test_failed_tests_xsl_escapes_backslashes(): void + { + $prunedXml = << + + Tests\Namespace\ClassName::testMethod + +XML; + + $tempFile = tempnam(sys_get_temp_dir(), 'pruned'); + file_put_contents($tempFile, $prunedXml); + + $filter = trim(shell_exec("xmlstarlet tr --omit-decl {$this->projectRoot}/../failed-tests.xsl {$tempFile}")); + + // Backslashes should be escaped for PHPUnit filter + $this->assertStringContainsString('Tests\\\\Namespace\\\\ClassName::testMethod', $filter); + + unlink($tempFile); + } + + public function test_full_transformation_pipeline(): void + { + $junitXml = << + + + + + + Failed + + + + Failed + + + +XML; + + $tempFile = tempnam(sys_get_temp_dir(), 'junit'); + file_put_contents($tempFile, $junitXml); + + // Run full pipeline: prune -> count + $count = trim(shell_exec("xmlstarlet tr {$this->projectRoot}/../prune.xsl {$tempFile} | xmlstarlet tr --omit-decl {$this->projectRoot}/../count-failed.xsl")); + $this->assertEquals('2', $count); + + // Run full pipeline: prune -> failed-tests + $filter = trim(shell_exec("xmlstarlet tr {$this->projectRoot}/../prune.xsl {$tempFile} | xmlstarlet tr --omit-decl {$this->projectRoot}/../failed-tests.xsl")); + $this->assertStringContainsString('Tests\\\\FailingTest::test_fail_1', $filter); + $this->assertStringContainsString('Tests\\\\FailingTest::test_fail_2', $filter); + $this->assertStringContainsString('|', $filter); + + unlink($tempFile); + } +}