├── .gitignore ├── src └── BehatExpectException │ ├── ExceptionExpectationFailed.php │ ├── ExpectedAnException.php │ └── ExpectException.php ├── rector.php ├── ecs.php ├── composer.json ├── .github └── workflows │ └── code_analysis.yaml ├── LICENSE ├── features ├── expecting_an_exception.feature └── bootstrap │ └── FeatureContext.php └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | /vendor/ 3 | /.idea -------------------------------------------------------------------------------- /src/BehatExpectException/ExceptionExpectationFailed.php: -------------------------------------------------------------------------------- 1 | withPaths([ 9 | __DIR__ . '/features', 10 | __DIR__ . '/src', 11 | ]) 12 | ->withRootFiles() 13 | ->withPhpSets(php84: true); 14 | -------------------------------------------------------------------------------- /src/BehatExpectException/ExpectedAnException.php: -------------------------------------------------------------------------------- 1 | withPaths([ 9 | __DIR__ . '/features', 10 | __DIR__ . '/src', 11 | ]) 12 | ->withRootFiles() 13 | ->withPreparedSets( 14 | psr12: true, 15 | common: true 16 | ); 17 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "matthiasnoback/behat-expect-exception", 3 | "require": { 4 | "php": "^8.4" 5 | }, 6 | "require-dev": { 7 | "behat/behat": "^3.24", 8 | "symplify/easy-coding-standard": "^12.4", 9 | "symplify/coding-standard": "^12.4", 10 | "rector/rector": "^2.1" 11 | }, 12 | "autoload": { 13 | "psr-4": { 14 | "": "src/" 15 | } 16 | }, 17 | "license": "MIT", 18 | "scripts": { 19 | "fix": [ 20 | "vendor/bin/rector", 21 | "vendor/bin/ecs --fix" 22 | ], 23 | "test": [ 24 | "vendor/bin/behat" 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/code_analysis.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: null 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | code_analysis: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | actions: 15 | - 16 | name: "Behat" 17 | run: vendor/bin/behat 18 | - 19 | name: "Code style" 20 | run: vendor/bin/ecs check --ansi 21 | dependencies: ["lowest", "highest"] 22 | name: ${{ matrix.actions.name }} 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | 28 | # see https://github.com/shivammathur/setup-php 29 | - uses: shivammathur/setup-php@v2 30 | with: 31 | php-version: 8.4 32 | coverage: none 33 | 34 | # composer install cache - https://github.com/ramsey/composer-install 35 | - uses: "ramsey/composer-install@v1" 36 | with: 37 | dependency-versions: "${{ matrix.dependencies }}" 38 | 39 | - run: ${{ matrix.actions.run }} 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Matthias Noback 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/BehatExpectException/ExpectException.php: -------------------------------------------------------------------------------- 1 | caughtException = $exception; 28 | } 29 | } 30 | 31 | private function mayFail(callable $function): void 32 | { 33 | try { 34 | $function(); 35 | } catch (Exception $exception) { 36 | $this->caughtException = $exception; 37 | } 38 | } 39 | 40 | private function assertCaughtExceptionMatches( 41 | string $expectedExceptionClass, 42 | ?string $messageShouldContain = null 43 | ) { 44 | if (! $this->caughtException instanceof Exception) { 45 | throw new ExceptionExpectationFailed('No exception was caught. Call $this->shouldFail() or $this->mayFail() first'); 46 | } 47 | 48 | if (! $this->caughtException instanceof $expectedExceptionClass) { 49 | throw new ExceptionExpectationFailed( 50 | sprintf( 51 | 'Expected the caught exception to be of type %s, caught an exception of type %s instead', 52 | $expectedExceptionClass, 53 | $this->caughtException::class 54 | ) 55 | ); 56 | } 57 | 58 | if ($messageShouldContain !== null 59 | && stripos($this->caughtException->getMessage(), $messageShouldContain) === false) { 60 | throw new ExceptionExpectationFailed( 61 | sprintf( 62 | 'Expected the message of the caught exception to contain %s. The actual message was: %s', 63 | $messageShouldContain, 64 | $this->caughtException->getMessage() 65 | ) 66 | ); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /features/expecting_an_exception.feature: -------------------------------------------------------------------------------- 1 | Feature: 2 | 3 | Scenario: The expected exception class with the expected message was thrown 4 | When a step runs some code that throws a RuntimeException with a message "The actual message" 5 | Then another step can confirm that the expected RuntimeException with a message containing "actual message" was thrown 6 | 7 | Scenario: Casing is irrelevant when comparing exception messages 8 | When a step runs some code that throws a RuntimeException with a message "The actual message" 9 | Then another step can confirm that the expected RuntimeException with a message containing "aCtUaL mEsSaGe" was thrown 10 | 11 | Scenario: The expected exception class was thrown (we ignore the message) 12 | When a step runs some code that throws a RuntimeException with a message "Irrelevant message" 13 | Then another step can confirm that the expected RuntimeException was thrown 14 | 15 | Scenario: A different exception with the same message was thrown 16 | When a step runs some code that throws a RuntimeException with a message "The same message" 17 | Then another step will fail to confirm that the expected LogicException with a message containing "The same message" was thrown 18 | 19 | Scenario: The same exception with a different message was thrown 20 | When a step runs some code that throws a RuntimeException with a message "The same message" 21 | Then another step will fail to confirm that the expected RuntimeException with a message containing "A different message" was thrown 22 | 23 | Scenario: A different exception with different message was thrown 24 | When a step runs some code that throws a RuntimeException with a message "The same message" 25 | Then another step will fail to confirm that the expected LogicException with a message containing "A different message" was thrown 26 | 27 | Scenario: The code does not even throw an exception 28 | When a step runs some code that is expected to fail but does not throw an exception 29 | Then that step will have thrown an ExpectedAnException 30 | 31 | Scenario: The code may throw an exception 32 | When a step runs some code that may throw an exception but does not throw it 33 | Then another step will fail to confirm that the expected RuntimeException with a message containing "actual message" was thrown 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | ```bash 4 | composer require --dev matthiasnoback/behat-expect-exception 5 | ``` 6 | 7 | ## Purpose 8 | 9 | This library lets you run code in one step definition that is expected to thrown an exception, then in another step definition allows you to verify that the correct exception was caught. Just like with PHPUnit you can compare the type of the caught exception to the expected type, and you can check if the actual exception message contains a given string. 10 | 11 | ## Usage example 12 | 13 | ```php 14 | use Behat\Behat\Context\Context; 15 | use BehatExpectException\ExpectException; 16 | 17 | final class FeatureContext implements Context 18 | { 19 | // Use this trait in your feature context: 20 | use ExpectException; 21 | 22 | /** 23 | * @When I try to make a reservation for :numberOfSeats seats 24 | */ 25 | public function iTryToMakeAReservation(int $numberOfSeats): void 26 | { 27 | /* 28 | * Catch an exception using $this->shouldFail(). 29 | * If the code in the callable doesn't throw an exception, shouldFail() 30 | * itself will throw an ExpectedAnException exception. 31 | */ 32 | 33 | $this->shouldFail( 34 | function () use ($numberOfSeats) { 35 | // This will throw a CouldNotMakeReservation exception: 36 | $this->reservationService()->makeReservation($numberOfSeats); 37 | } 38 | ); 39 | } 40 | 41 | /** 42 | * @Then I should see an error message saying: :message 43 | */ 44 | public function confirmCaughtExceptionMatchesExpectedTypeAndMessage(string $message): void 45 | { 46 | $this->assertCaughtExceptionMatches( 47 | CouldNotMakeReservation::class, 48 | $message 49 | ); 50 | } 51 | 52 | /** 53 | * @When I make a reservation for :numberOfSeats seats 54 | */ 55 | public function iMakeAReservation(int $numberOfSeats): void 56 | { 57 | /* 58 | * Catch a possible exception using $this->mayFail(). 59 | * If the code in the callable doesn't throw an exception, 60 | * then it's not a problem. mayFail() doesn't throw an 61 | * ExpectedAnException exception itself in that case. 62 | * You can still use assertCaughtExceptionMatches(), but 63 | * it will throw an ExpectedAnException if no exception was 64 | * caught. 65 | */ 66 | 67 | $this->mayFail( 68 | function () use ($numberOfSeats) { 69 | // This might throw a CouldNotMakeReservation exception: 70 | $this->reservationService()->makeReservation($numberOfSeats); 71 | } 72 | ); 73 | } 74 | } 75 | ``` 76 | 77 | ## Maintenance 78 | 79 | - Run `composer install` to install project dependencies (requires PHP 8.4 and Composer globally installed) 80 | - Run `composer fix` to fix coding style 81 | - Run `composer test` to run the tests 82 | -------------------------------------------------------------------------------- /features/bootstrap/FeatureContext.php: -------------------------------------------------------------------------------- 1 | shouldFail( 18 | function () use ($exceptionClass, $exceptionMessage) { 19 | throw new $exceptionClass($exceptionMessage); 20 | } 21 | ); 22 | } 23 | 24 | /** 25 | * @Then another step can confirm that the expected :exceptionClass with a message containing :messageContains was thrown 26 | */ 27 | public function confirmCaughtExceptionMatchesExpectedTypeAndMessage( 28 | string $exceptionClass, 29 | string $messageContains 30 | ): void { 31 | $this->assertCaughtExceptionMatches( 32 | $exceptionClass, 33 | $messageContains 34 | ); 35 | } 36 | 37 | /** 38 | * @Then another step can confirm that the expected :exceptionClass was thrown 39 | */ 40 | public function confirmCaughtExceptionMatchesExpectedType( 41 | string $exceptionClass 42 | ): void { 43 | $this->assertCaughtExceptionMatches($exceptionClass); 44 | } 45 | 46 | /** 47 | * @Then another step will fail to confirm that the expected :exceptionClass with a message containing :messageContains was thrown 48 | */ 49 | public function failToConfirmCaughtExceptionMatchesExpectedTypeAndMessage( 50 | string $exceptionClass, 51 | string $messageContains 52 | ): void { 53 | try { 54 | $this->assertCaughtExceptionMatches( 55 | $exceptionClass, 56 | $messageContains 57 | ); 58 | 59 | throw new RuntimeException('The code above should have thrown an ExceptionExpectationFailed exception'); 60 | } catch (ExceptionExpectationFailed) { 61 | // this was supposed to happen 62 | } 63 | } 64 | 65 | /** 66 | * @When a step runs some code that is expected to fail but does not throw an exception 67 | */ 68 | public function failToEvenFail(): void 69 | { 70 | try { 71 | $this->shouldFail( 72 | function () { 73 | // does not fail, contrary to expectations 74 | } 75 | ); 76 | } catch (ExpectedAnException $exception) { 77 | // we catch this one now so we can verify that it has been thrown 78 | $this->caughtException = $exception; 79 | } 80 | } 81 | 82 | /** 83 | * @Then that step will have thrown an ExpectedAnException 84 | */ 85 | public function confirmExceptionIsExpectedAnException(): void 86 | { 87 | $this->assertCaughtExceptionMatches(ExpectedAnException::class); 88 | } 89 | 90 | /** 91 | * @When a step runs some code that may throw an exception but does not throw it 92 | */ 93 | public function whenAStepMayThrowAnExceptionButDoesNot(): void 94 | { 95 | try { 96 | $this->mayFail( 97 | function () { 98 | // may fail, but does not fail 99 | } 100 | ); 101 | } catch (Exception) { 102 | throw new RuntimeException('The step was not supposed to fail'); 103 | } 104 | } 105 | } 106 | --------------------------------------------------------------------------------