├── .gitignore ├── .travis.yml ├── spec ├── Exception │ ├── ExpectationExceptionSpec.php │ ├── ErrorExceptionSpec.php │ └── RunnableExceptionSpec.php ├── Runnable │ ├── HookSpec.php │ ├── RunnableSpec.php │ └── SpecSpec.php ├── Expectation │ └── Matcher │ │ ├── MockMatcher.php │ │ ├── RangeMatcherSpec.php │ │ ├── MinimumMatcherSpec.php │ │ ├── MaximumMatcherSpec.php │ │ ├── StrictEqualityMatcherSpec.php │ │ ├── LooseEqualityMatcherSpec.php │ │ ├── TypeMatcherSpec.php │ │ ├── PatternMatcherSpec.php │ │ ├── SuffixMatcherSpec.php │ │ ├── PrefixMatcherSpec.php │ │ ├── ArrayKeyMatcherSpec.php │ │ ├── InstanceMatcherSpec.php │ │ ├── PrintMatcherSpec.php │ │ ├── LengthMatcherSpec.php │ │ ├── ExceptionMatcherSpec.php │ │ └── InclusionMatcherSpec.php ├── Reporter │ ├── AbstractReporterSpec.php │ ├── ListReporterSpec.php │ ├── DotReporterSpec.php │ └── SpecReporterSpec.php ├── Console │ ├── ConsoleOptionSpec.php │ ├── ConsoleFormatterSpec.php │ ├── ConsoleOptionParserSpec.php │ └── ConsoleSpec.php └── Suite │ └── SuiteSpec.php ├── bin ├── compile └── pho ├── src ├── Runnable │ ├── Hook.php │ ├── Runnable.php │ └── Spec.php ├── Exception │ ├── RunnableException.php │ ├── ReporterNotFoundException.php │ ├── Exception.php │ ├── ExpectationException.php │ └── ErrorException.php ├── Expectation │ └── Matcher │ │ ├── AbstractMatcher.php │ │ ├── MatcherInterface.php │ │ ├── MaximumMatcher.php │ │ ├── MinimumMatcher.php │ │ ├── PrefixMatcher.php │ │ ├── TypeMatcher.php │ │ ├── PatternMatcher.php │ │ ├── ArrayKeyMatcher.php │ │ ├── StrictEqualityMatcher.php │ │ ├── InstanceMatcher.php │ │ ├── SuffixMatcher.php │ │ ├── PrintMatcher.php │ │ ├── LooseEqualityMatcher.php │ │ ├── RangeMatcher.php │ │ ├── ExceptionMatcher.php │ │ ├── LengthMatcher.php │ │ └── InclusionMatcher.php ├── Reporter │ ├── ReporterInterface.php │ ├── ListReporter.php │ ├── DotReporter.php │ ├── SpecReporter.php │ └── AbstractReporter.php ├── Console │ ├── ConsoleOption.php │ ├── ConsoleFormatter.php │ ├── ConsoleOptionParser.php │ └── Console.php ├── globalPho.php ├── Watcher │ └── Watcher.php ├── Suite │ └── Suite.php ├── pho.php └── Runner │ └── Runner.php ├── composer.json ├── CHANGELOG.md ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | .DS_Store 3 | .idea 4 | composer.phar 5 | composer.lock 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.0 5 | - 5.6 6 | - 5.5 7 | - 5.4 8 | - hhvm 9 | 10 | matrix: 11 | fast_finish: true 12 | 13 | before_script: 14 | - composer self-update 15 | - composer install 16 | 17 | script: ./bin/pho -a --reporter spec 18 | -------------------------------------------------------------------------------- /spec/Exception/ExpectationExceptionSpec.php: -------------------------------------------------------------------------------- 1 | toBe(true); 10 | }); 11 | 12 | it('contains the message on toString', function() use ($exception) { 13 | expect((string) $exception)->toContain('test message'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /spec/Exception/ErrorExceptionSpec.php: -------------------------------------------------------------------------------- 1 | toBe(true); 11 | }); 12 | 13 | it('uses the error level as the type', function() use ($exception) { 14 | expect($exception->getType())->toEqual('E_ERROR'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /spec/Exception/RunnableExceptionSpec.php: -------------------------------------------------------------------------------- 1 | toBe(true); 12 | }); 13 | 14 | it('uses the exception class as the type', function() use ($exception) { 15 | expect($exception->getType())->toEqual('Exception'); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /bin/compile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | buildFromDirectory($rootDir, '/[src|vendor]\/.+\.php$/'); 23 | $phar->setStub($stub); 24 | -------------------------------------------------------------------------------- /spec/Runnable/HookSpec.php: -------------------------------------------------------------------------------- 1 | suite = new Suite('TestSuite', function() {}); 10 | }); 11 | 12 | it('has its closure bound to the suite', function() { 13 | $suite = $this->suite; 14 | $suite->key = 'testvalue'; 15 | 16 | $run = function() { 17 | $closure = function() { 18 | echo $this->key; 19 | }; 20 | $hook = new Hook('fake', $closure, $this->suite); 21 | $hook->run(); 22 | }; 23 | 24 | expect($run)->toPrint('testvalue'); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /bin/pho: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | title = "{$title} hook"; 21 | $this->suite = $suite; 22 | $this->closure = $closure->bindTo($suite); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Exception/RunnableException.php: -------------------------------------------------------------------------------- 1 | getMessage(), $exception->getCode(), 19 | $exception); 20 | 21 | $this->file = $exception->getFile(); 22 | $this->line = $exception->getLine(); 23 | $this->type = get_class($exception); 24 | } 25 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "danielstjules/pho", 3 | "description": "A BDD testing framework", 4 | "keywords": [ 5 | "bdd", "behavior driven development", "framework", "pho", "test", "unit" 6 | ], 7 | "homepage": "https://github.com/danielstjules/pho", 8 | "license": "MIT", 9 | "authors": [ 10 | { 11 | "name": "Daniel St. Jules", 12 | "email": "danielst.jules@gmail.com", 13 | "homepage": "http://www.danielstjules.com" 14 | } 15 | ], 16 | "require": { 17 | "php": ">=5.4.0" 18 | }, 19 | "support": { 20 | "issues": "https://github.com/danielstjules/pho/issues", 21 | "source": "https://github.com/danielstjules/pho" 22 | }, 23 | "autoload": { 24 | "psr-4": { "pho\\": "src/" } 25 | }, 26 | "bin": ["bin/pho"] 27 | } 28 | -------------------------------------------------------------------------------- /src/Exception/ReporterNotFoundException.php: -------------------------------------------------------------------------------- 1 | getMessage(), 21 | $exception->getCode(), 22 | $exception 23 | ); 24 | 25 | $this->file = $exception->getFile(); 26 | $this->line = $exception->getLine(); 27 | $this->type = get_class($exception); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/Expectation/Matcher/AbstractMatcher.php: -------------------------------------------------------------------------------- 1 | type; 18 | } 19 | 20 | /** 21 | * Returns a string containing the Exception type, message, filename and 22 | * line in human readable form for use by Reporters and the command line 23 | * runner. 24 | * 25 | * @return string A human readable description of the exception 26 | */ 27 | public function __toString() 28 | { 29 | return "{$this->file}:{$this->line}\n" . 30 | "{$this->type} with message \"{$this->message}\""; 31 | } 32 | } -------------------------------------------------------------------------------- /src/Expectation/Matcher/MatcherInterface.php: -------------------------------------------------------------------------------- 1 | expected = $expected; 20 | } 21 | 22 | /** 23 | * Returns true if passed the expected value, false otherwise. 24 | * 25 | * @param mixed $value The actual value to test 26 | * @return boolean Whether or not the value is strictly equal 27 | */ 28 | public function match($value) 29 | { 30 | return ($value === $this->expected); 31 | } 32 | 33 | /** 34 | * Returns an error message indicating why the match failed, and the 35 | * negation of the message if $negated is true. 36 | * 37 | * @param boolean $negated Whether or not to print the negated message 38 | * @return string The error message 39 | */ 40 | public function getFailureMessage($negated = false) 41 | { 42 | if (!$negated) { 43 | return "Expected value to be {$this->expected}"; 44 | } else { 45 | return "Expected value not to be {$this->expected}"; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Exception/ExpectationException.php: -------------------------------------------------------------------------------- 1 | -1 && isset($stack[$pos]['file']) && isset($stack[$pos]['line'])) { 29 | $this->file = $stack[$pos]['file']; 30 | $this->line = $stack[$pos]['line']; 31 | } 32 | } 33 | 34 | /** 35 | * Returns a string containing the expectation failure message. 36 | * 37 | * @return string A human readable description of the exception 38 | */ 39 | public function __toString() 40 | { 41 | return "{$this->file}:{$this->line}\n$this->message"; 42 | } 43 | } -------------------------------------------------------------------------------- /src/Expectation/Matcher/MaximumMatcher.php: -------------------------------------------------------------------------------- 1 | maximum = $maximum; 19 | } 20 | 21 | /** 22 | * Returns true if $actual is less than $maximum, false otherwise. 23 | * 24 | * @param mixed $actual The value to compare 25 | * @return boolean Whether or not the value is less than the min 26 | */ 27 | public function match($actual) 28 | { 29 | $this->actual = $actual; 30 | 31 | return ($this->actual < $this->maximum); 32 | } 33 | 34 | /** 35 | * Returns an error message indicating why the match failed, and the 36 | * negation of the message if $negated is true. 37 | * 38 | * @param boolean $negated Whether or not to print the negated message 39 | * @return string The error message 40 | */ 41 | public function getFailureMessage($negated = false) 42 | { 43 | if (!$negated) { 44 | return "Expected {$this->actual} to be less than {$this->maximum}"; 45 | } else { 46 | return "Expected {$this->actual} not to be less than {$this->maximum}"; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Expectation/Matcher/MinimumMatcher.php: -------------------------------------------------------------------------------- 1 | minimum = $minimum; 19 | } 20 | 21 | /** 22 | * Returns true if $actual is greater than $minimum, false otherwise. 23 | * 24 | * @param mixed $actual The value to compare 25 | * @return boolean Whether or not the value is greater than the min 26 | */ 27 | public function match($actual) 28 | { 29 | $this->actual = $actual; 30 | 31 | return ($this->actual > $this->minimum); 32 | } 33 | 34 | /** 35 | * Returns an error message indicating why the match failed, and the 36 | * negation of the message if $negated is true. 37 | * 38 | * @param boolean $negated Whether or not to print the negated message 39 | * @return string The error message 40 | */ 41 | public function getFailureMessage($negated = false) 42 | { 43 | if (!$negated) { 44 | return "Expected {$this->actual} to be greater than {$this->minimum}"; 45 | } else { 46 | return "Expected {$this->actual} not to be greater than {$this->minimum}"; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Expectation/Matcher/PrefixMatcher.php: -------------------------------------------------------------------------------- 1 | prefix = $prefix; 19 | } 20 | 21 | /** 22 | * Returns true if the subject starts with the given prefix, false otherwise. 23 | * 24 | * @param mixed $subject The string to test 25 | * @return boolean Whether or not the string contains the prefix 26 | */ 27 | public function match($subject) 28 | { 29 | $this->subject = $subject; 30 | 31 | return (strpos($subject, $this->prefix) === 0); 32 | } 33 | 34 | /** 35 | * Returns an error message indicating why the match failed, and the 36 | * negation of the message if $negated is true. 37 | * 38 | * @param boolean $negated Whether or not to print the negated message 39 | * @return string The error message 40 | */ 41 | public function getFailureMessage($negated = false) 42 | { 43 | if (!$negated) { 44 | return "Expected \"{$this->subject}\" to start with \"{$this->prefix}\""; 45 | } else { 46 | return "Expected \"{$this->subject}\" not to start with \"{$this->prefix}\""; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Expectation/Matcher/TypeMatcher.php: -------------------------------------------------------------------------------- 1 | expected = $expected; 19 | } 20 | 21 | /** 22 | * Compares the type of the passed argument to the expected type. Returns 23 | * true if the two values are of the same type, false otherwise. 24 | * 25 | * @param mixed $actual The value to test 26 | * @return boolean Whether or not the value has the expected type 27 | */ 28 | public function match($actual) 29 | { 30 | $this->actual = gettype($actual); 31 | 32 | return ($this->actual === $this->expected); 33 | } 34 | 35 | /** 36 | * Returns an error message indicating why the match failed, and the 37 | * negation of the message if $negated is true. 38 | * 39 | * @param boolean $negated Whether or not to print the negated message 40 | * @return string The error message 41 | */ 42 | public function getFailureMessage($negated = false) 43 | { 44 | if (!$negated) { 45 | return "Expected {$this->expected}, got {$this->actual}"; 46 | } else { 47 | return "Expected a type other than {$this->expected}"; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Expectation/Matcher/PatternMatcher.php: -------------------------------------------------------------------------------- 1 | pattern = $pattern; 20 | } 21 | 22 | /** 23 | * Tries to match the given string with the expected pattern. Returns 24 | * true if it matches, false otherwise. 25 | * 26 | * @param mixed $subject The string to test 27 | * @return boolean Whether or not the string matches the expect pattern 28 | */ 29 | public function match($subject) 30 | { 31 | $this->subject = $subject; 32 | 33 | return (preg_match($this->pattern, $subject) > 0); 34 | } 35 | 36 | /** 37 | * Returns an error message indicating why the match failed, and the 38 | * negation of the message if $negated is true. 39 | * 40 | * @param boolean $negated Whether or not to print the negated message 41 | * @return string The error message 42 | */ 43 | public function getFailureMessage($negated = false) 44 | { 45 | if (!$negated) { 46 | return "Expected \"{$this->subject}\" to match {$this->pattern}"; 47 | } else { 48 | return "Expected \"{$this->subject}\" not to match {$this->pattern}"; 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Expectation/Matcher/ArrayKeyMatcher.php: -------------------------------------------------------------------------------- 1 | key = $key; 19 | } 20 | 21 | /** 22 | * Returns true if the key exists in the given array, false otherwise. 23 | * 24 | * @param array $array The array in which to check for the key 25 | * @return boolean Whether or not the key exists in the array 26 | * @throws \InvalidArgumentException If $array isn't an array 27 | */ 28 | public function match($array) 29 | { 30 | if (!is_array($array)) { 31 | throw new \InvalidArgumentException('Argument must be an array'); 32 | } 33 | 34 | return (array_key_exists($this->key, $array)); 35 | } 36 | 37 | /** 38 | * Returns an error message indicating why the match failed, and the 39 | * negation of the message if $negated is true. 40 | * 41 | * @param boolean $negated Whether or not to print the negated message 42 | * @return string The error message 43 | */ 44 | public function getFailureMessage($negated = false) 45 | { 46 | if (!$negated) { 47 | return "Expected array to have the key \"{$this->key}\""; 48 | } else { 49 | return "Expected array not to have the key \"{$this->key}\""; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Expectation/Matcher/StrictEqualityMatcher.php: -------------------------------------------------------------------------------- 1 | expected = $expected; 19 | } 20 | 21 | /** 22 | * Compares the passed argument to the expected value. Returns true if the 23 | * two values are strictly equal, false otherwise. 24 | * 25 | * @param mixed $actual The value to test 26 | * @return boolean Whether or not the value is equal 27 | */ 28 | public function match($actual) 29 | { 30 | $this->actual = $actual; 31 | 32 | return ($this->actual === $this->expected); 33 | } 34 | 35 | /** 36 | * Returns an error message indicating why the match failed, and the 37 | * negation of the message if $negated is true. 38 | * 39 | * @param boolean $negated Whether or not to print the negated message 40 | * @return string The error message 41 | */ 42 | public function getFailureMessage($negated = false) 43 | { 44 | $actual = $this->getStringValue($this->actual); 45 | $expected = $this->getStringValue($this->expected); 46 | 47 | if (!$negated) { 48 | return "Expected $actual to be $expected"; 49 | } else { 50 | return "Expected $actual not to be $expected"; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Expectation/Matcher/InstanceMatcher.php: -------------------------------------------------------------------------------- 1 | expected = $expected; 19 | } 20 | 21 | /** 22 | * Compares the class of the passed argument to the expected class name. 23 | * Returns true if $actual is an instance of the expected class, else false. 24 | * 25 | * @param mixed $actual The object to compare 26 | * @return boolean Whether or not an instance of the expected class 27 | */ 28 | public function match($actual) 29 | { 30 | $this->actual = $actual; 31 | 32 | return ($this->actual instanceof $this->expected); 33 | } 34 | 35 | /** 36 | * Returns an error message indicating why the match failed, and the 37 | * negation of the message if $negated is true. 38 | * 39 | * @param boolean $negated Whether or not to print the negated message 40 | * @return string The error message 41 | */ 42 | public function getFailureMessage($negated = false) 43 | { 44 | $actualClass = get_class($this->actual); 45 | 46 | if (!$negated) { 47 | return "Expected an instance of {$this->expected}, got {$actualClass}"; 48 | } else { 49 | return "Expected an instance other than {$this->expected}"; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Expectation/Matcher/SuffixMatcher.php: -------------------------------------------------------------------------------- 1 | suffix = $suffix; 19 | } 20 | 21 | /** 22 | * Returns true if the subject ends with the given suffix, false otherwise. 23 | * 24 | * @param mixed $subject The string to test 25 | * @return boolean Whether or not the string contains the suffix 26 | */ 27 | public function match($subject) 28 | { 29 | $this->subject = $subject; 30 | 31 | $suffixLength = strlen($this->suffix); 32 | if (!$suffixLength) { 33 | return true; 34 | } 35 | 36 | return (substr($subject, -$suffixLength) === $this->suffix); 37 | } 38 | 39 | /** 40 | * Returns an error message indicating why the match failed, and the 41 | * negation of the message if $negated is true. 42 | * 43 | * @param boolean $negated Whether or not to print the negated message 44 | * @return string The error message 45 | */ 46 | public function getFailureMessage($negated = false) 47 | { 48 | if (!$negated) { 49 | return "Expected \"{$this->subject}\" to end with \"{$this->suffix}\""; 50 | } else { 51 | return "Expected \"{$this->subject}\" not to end with \"{$this->suffix}\""; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Expectation/Matcher/PrintMatcher.php: -------------------------------------------------------------------------------- 1 | expected = $expected; 19 | } 20 | 21 | /** 22 | * Compares the output printed by the callable to the expected output. 23 | * Returns true if the two strings are equal, false otherwise. 24 | * 25 | * @param callable $callable The function to invoke 26 | * @return boolean Whether or not the printed and expected output are equal 27 | */ 28 | public function match($callable) 29 | { 30 | ob_start(); 31 | $callable(); 32 | 33 | $this->actual = ob_get_contents(); 34 | ob_end_clean(); 35 | 36 | return ($this->actual == $this->expected); 37 | } 38 | 39 | /** 40 | * Returns an error message indicating why the match failed, and the 41 | * negation of the message if $negated is true. 42 | * 43 | * @param boolean $negated Whether or not to print the negated message 44 | * @return string The error message 45 | */ 46 | public function getFailureMessage($negated = false) 47 | { 48 | if (!$negated) { 49 | return "Expected \"{$this->expected}\", got \"{$this->actual}\""; 50 | } else { 51 | return "Expected output other than \"{$this->expected}\""; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Expectation/Matcher/LooseEqualityMatcher.php: -------------------------------------------------------------------------------- 1 | expected = $expected; 19 | } 20 | 21 | /** 22 | * Compares the passed argument to the expected value. Returns true if the 23 | * two values are loosely equal using the == operator, false otherwise. 24 | * 25 | * @param mixed $actual The value to test 26 | * @return boolean Whether or not the value is equal 27 | */ 28 | public function match($actual) 29 | { 30 | $this->actual = $actual; 31 | 32 | return ($this->actual == $this->expected); 33 | } 34 | 35 | /** 36 | * Returns an error message indicating why the match failed, and the 37 | * negation of the message if $negated is true. 38 | * 39 | * @param boolean $negated Whether or not to print the negated message 40 | * @return string The error message 41 | */ 42 | public function getFailureMessage($negated = false) 43 | { 44 | $actual = $this->getStringValue($this->actual); 45 | $expected = $this->getStringValue($this->expected); 46 | 47 | if (!$negated) { 48 | return "Expected $actual to equal $expected"; 49 | } else { 50 | return "Expected $actual not to equal $expected"; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Expectation/Matcher/RangeMatcher.php: -------------------------------------------------------------------------------- 1 | start = $start; 21 | $this->end = $end; 22 | } 23 | 24 | /** 25 | * Returns true if $actual is within the inclusive range, false otherwise. 26 | * 27 | * @param mixed $actual The value to compare 28 | * @return boolean Whether or not the value is within the range 29 | */ 30 | public function match($actual) 31 | { 32 | $this->actual = $actual; 33 | 34 | return ($actual >= $this->start && $actual <= $this->end); 35 | } 36 | 37 | /** 38 | * Returns an error message indicating why the match failed, and the 39 | * negation of the message if $negated is true. 40 | * 41 | * @param boolean $negated Whether or not to print the negated message 42 | * @return string The error message 43 | */ 44 | public function getFailureMessage($negated = false) 45 | { 46 | $x = $this->start; 47 | $y = $this->end; 48 | 49 | if (!$negated) { 50 | return "Expected {$this->actual} to be within [$x, $y]"; 51 | } else { 52 | return "Expected {$this->actual} not to be within [$x, $y]"; 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Reporter/ReporterInterface.php: -------------------------------------------------------------------------------- 1 | 'E_ERROR', 11 | E_WARNING => 'E_WARNING', 12 | E_PARSE => 'E_PARSE', 13 | E_NOTICE => 'E_NOTICE', 14 | E_CORE_ERROR => 'E_CORE_ERROR', 15 | E_CORE_WARNING => 'E_CORE_WARNING', 16 | E_CORE_ERROR => 'E_CORE_ERROR', 17 | E_CORE_WARNING => 'E_CORE_WARNING', 18 | E_USER_ERROR => 'E_USER_ERROR', 19 | E_USER_WARNING => 'E_USER_WARNING', 20 | E_USER_NOTICE => 'E_USER_NOTICE', 21 | E_STRICT => 'E_STRICT', 22 | E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR', 23 | E_DEPRECATED => 'E_DEPRECATED', 24 | E_USER_DEPRECATED => 'E_USER_DEPRECATED' 25 | ]; 26 | 27 | /** 28 | * Creates an ErrorException, given the same parameters used by an error 29 | * handler used with set_error_handler(). The class only handles the default 30 | * PHP constant error levels. 31 | * 32 | * @param int $level The error level corresponding to the PHP error 33 | * @param string $string Error message itself 34 | * @param string $file The name of the file from which the error was raised 35 | * @param int $line The line number from which the error was raised 36 | */ 37 | public function __construct($level, $string, $file = null, $line = null) 38 | { 39 | parent::__construct($string, 0); 40 | 41 | $this->file = $file; 42 | $this->line = $line; 43 | $this->type = self::$errorConstants[$level]; 44 | } 45 | } -------------------------------------------------------------------------------- /spec/Reporter/AbstractReporterSpec.php: -------------------------------------------------------------------------------- 1 | specCount += 1; 17 | } 18 | 19 | public function afterSpec(Spec $spec) 20 | { 21 | return; 22 | } 23 | } 24 | 25 | describe('AbstractReporter', function() { 26 | $console = new Console([], 'php://output'); 27 | $console->parseArguments(); 28 | 29 | $reporter = new MockReporter($console); 30 | $this->reporter = $reporter; 31 | 32 | context('afterRun', function() { 33 | before(function() { 34 | // Add a spec and run corresponding reporter hooks 35 | $reporter = $this->reporter; 36 | $suite = new Suite('test', function(){}); 37 | $spec = new Spec('testspec', function(){}, $suite); 38 | $reporter->beforeSpec($spec); 39 | $reporter->afterSpec($spec); 40 | 41 | ob_start(); 42 | $this->reporter->afterRun(); 43 | $this->printContents = ob_get_contents(); 44 | ob_end_clean(); 45 | }); 46 | 47 | it('prints the running time', function() { 48 | // TODO: Update once pattern matching is added 49 | $print = $this->printContents; 50 | expect($print)->toContain('Finished in'); 51 | expect($print)->toContain('seconds'); 52 | }); 53 | 54 | it('prints the number of specs and failures', function() { 55 | expect($this->printContents)->toContain('1 spec, 0 failures'); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /spec/Runnable/RunnableSpec.php: -------------------------------------------------------------------------------- 1 | suite = $suite; 16 | $this->closure = $closure->bindTo($suite); 17 | } 18 | } 19 | 20 | describe('Runnable', function() { 21 | before(function() { 22 | $this->suite = new Suite('TestSuite', function() {}); 23 | }); 24 | 25 | context('run', function() { 26 | it('catches and stores errors', function() { 27 | $closure = function() { 28 | trigger_error('TestError', E_USER_NOTICE); 29 | }; 30 | $runnable = new MockRunnable($closure, $this->suite); 31 | $runnable->run(); 32 | 33 | expect($runnable->getException()->getType())->toEqual('E_USER_NOTICE'); 34 | }); 35 | 36 | it('catches and stores ExpectationExceptions', function() { 37 | $closure = function() { 38 | throw new ExpectationException('test'); 39 | }; 40 | $runnable = new MockRunnable($closure, $this->suite); 41 | $runnable->run(); 42 | 43 | expect($runnable->getException()->getMessage())->toEqual('test'); 44 | }); 45 | 46 | it('catches and stores all other exceptions', function() { 47 | $closure = function() { 48 | throw new \Exception('test exception'); 49 | }; 50 | $runnable = new MockRunnable($closure, $this->suite); 51 | $runnable->run(); 52 | 53 | expect($runnable->getException()->getMessage())->toEqual('test exception'); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/Expectation/Matcher/ExceptionMatcher.php: -------------------------------------------------------------------------------- 1 | expected = $expected; 19 | } 20 | 21 | /** 22 | * Compares the exception thrown by the callable, if any, to the expected 23 | * exception. Returns true if an exception of the expected class is thrown, 24 | * false otherwise. 25 | * 26 | * @param callable $callable The function to invoke 27 | * @return boolean Whether or not the function threw the expected exception 28 | */ 29 | public function match($callable) 30 | { 31 | try { 32 | $callable(); 33 | } catch(\Exception $exception) { 34 | $this->thrown = $exception; 35 | } 36 | 37 | return ($this->thrown instanceof $this->expected); 38 | } 39 | 40 | /** 41 | * Returns an error message indicating why the match failed, and the 42 | * negation of the message if $negated is true. 43 | * 44 | * @param boolean $negated Whether or not to print the negated message 45 | * @return string The error message 46 | */ 47 | public function getFailureMessage($negated = false) 48 | { 49 | $explanation = 'none thrown'; 50 | if ($this->thrown) { 51 | $class = get_class($this->thrown); 52 | $explanation = "got $class"; 53 | } 54 | 55 | if (!$negated) { 56 | return "Expected {$this->expected} to be thrown, {$explanation}"; 57 | } else { 58 | return "Expected {$this->expected} not to be thrown"; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /spec/Expectation/Matcher/RangeMatcherSpec.php: -------------------------------------------------------------------------------- 1 | match(1)) { 19 | throw new \Exception('Does not return true'); 20 | } 21 | }); 22 | 23 | it('returns false if the value is not within the range', function() { 24 | $matcher = new RangeMatcher(1, 2); 25 | 26 | if ($matcher->match(2.1)) { 27 | throw new \Exception('Does not return false'); 28 | } 29 | }); 30 | }); 31 | 32 | context('getFailureMessage', function() { 33 | it('lists the range and actual values', function() { 34 | $matcher = new RangeMatcher(1, 2); 35 | $matcher->match(0); 36 | $expected = 'Expected 0 to be within [1, 2]'; 37 | 38 | if ($expected !== $matcher->getFailureMessage()) { 39 | throw new \Exception('Did not return expected failure message'); 40 | } 41 | }); 42 | 43 | it('lists the range and actual values with negated logic', function() { 44 | $matcher = new RangeMatcher(1.1, 10); 45 | $matcher->match(1.1); 46 | $expected = 'Expected 1.1 not to be within [1.1, 10]'; 47 | 48 | if ($expected !== $matcher->getFailureMessage(true)) { 49 | throw new \Exception('Did not return expected failure message'); 50 | } 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /spec/Expectation/Matcher/MinimumMatcherSpec.php: -------------------------------------------------------------------------------- 1 | match(2)) { 19 | throw new \Exception('Does not return true'); 20 | } 21 | }); 22 | 23 | it('returns false if the value is less than the min', function() { 24 | $matcher = new MinimumMatcher(2); 25 | 26 | if ($matcher->match(-1)) { 27 | throw new \Exception('Does not return false'); 28 | } 29 | }); 30 | }); 31 | 32 | context('getFailureMessage', function() { 33 | it('lists the minimum and the actual values', function() { 34 | $matcher = new MinimumMatcher(1); 35 | $matcher->match(1); 36 | $expected = 'Expected 1 to be greater than 1'; 37 | 38 | if ($expected !== $matcher->getFailureMessage()) { 39 | throw new \Exception('Did not return expected failure message'); 40 | } 41 | }); 42 | 43 | it('lists the minimum and actual values with negated logic', function() { 44 | $matcher = new MinimumMatcher(2); 45 | $matcher->match(3.1); 46 | $expected = 'Expected 3.1 not to be greater than 2'; 47 | 48 | if ($expected !== $matcher->getFailureMessage(true)) { 49 | throw new \Exception('Did not return expected failure message'); 50 | } 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /spec/Expectation/Matcher/MaximumMatcherSpec.php: -------------------------------------------------------------------------------- 1 | match(0)) { 19 | throw new \Exception('Does not return true'); 20 | } 21 | }); 22 | 23 | it('returns false if the value is greater than the max', function() { 24 | $matcher = new MaximumMatcher(-1); 25 | 26 | if ($matcher->match(10.1)) { 27 | throw new \Exception('Does not return false'); 28 | } 29 | }); 30 | }); 31 | 32 | context('getFailureMessage', function() { 33 | it('lists the maximum and actual values', function() { 34 | $matcher = new MaximumMatcher(1); 35 | $matcher->match(1); 36 | $expected = 'Expected 1 to be less than 1'; 37 | 38 | if ($expected !== $matcher->getFailureMessage()) { 39 | throw new \Exception('Did not return expected failure message'); 40 | } 41 | }); 42 | 43 | it('lists the maximum and actual values with negated logic', function() { 44 | $matcher = new MaximumMatcher(2.2); 45 | $matcher->match(2.1999); 46 | $expected = 'Expected 2.1999 not to be less than 2.2'; 47 | 48 | if ($expected !== $matcher->getFailureMessage(true)) { 49 | throw new \Exception('Did not return expected failure message'); 50 | } 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /spec/Expectation/Matcher/StrictEqualityMatcherSpec.php: -------------------------------------------------------------------------------- 1 | match(false)) { 19 | throw new \Exception('Does not return true'); 20 | } 21 | }); 22 | 23 | it('returns false if not strictly equal', function() { 24 | $matcher = new StrictEqualityMatcher(null); 25 | 26 | if ($matcher->match(false)) { 27 | throw new \Exception('Does not return false'); 28 | } 29 | }); 30 | }); 31 | 32 | context('getFailureMessage', function() { 33 | it('lists the expected equality', function() { 34 | $matcher = new StrictEqualityMatcher(0); 35 | $matcher->match(false); 36 | $expected = 'Expected false to be 0'; 37 | 38 | if ($expected !== $matcher->getFailureMessage()) { 39 | throw new \Exception('Did not return expected failure message'); 40 | } 41 | }); 42 | 43 | it('lists the expected equality with negated logic', function() { 44 | $matcher = new StrictEqualityMatcher(null); 45 | $matcher->match(null); 46 | $expected = 'Expected null not to be null'; 47 | 48 | if ($expected !== $matcher->getFailureMessage(true)) { 49 | throw new \Exception('Did not return expected failure message'); 50 | } 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /spec/Expectation/Matcher/LooseEqualityMatcherSpec.php: -------------------------------------------------------------------------------- 1 | match(1)) { 19 | throw new \Exception('Does not return true'); 20 | } 21 | }); 22 | 23 | it('returns false if not loosely equal', function() { 24 | $matcher = new LooseEqualityMatcher(false); 25 | 26 | if ($matcher->match(['a', 'b'])) { 27 | throw new \Exception('Does not return false'); 28 | } 29 | }); 30 | }); 31 | 32 | context('getFailureMessage', function() { 33 | it('lists the expected equality', function() { 34 | $matcher = new LooseEqualityMatcher(true); 35 | $matcher->match(null); 36 | $expected = 'Expected null to equal true'; 37 | 38 | if ($expected !== $matcher->getFailureMessage()) { 39 | throw new \Exception('Did not return expected failure message'); 40 | } 41 | }); 42 | 43 | it('lists the expected equality with negated logic', function() { 44 | $matcher = new LooseEqualityMatcher('test'); 45 | $matcher->match('test'); 46 | $expected = 'Expected "test" not to equal "test"'; 47 | 48 | if ($expected !== $matcher->getFailureMessage(true)) { 49 | throw new \Exception('Did not return expected failure message'); 50 | } 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /spec/Expectation/Matcher/TypeMatcherSpec.php: -------------------------------------------------------------------------------- 1 | match('test')) { 19 | throw new \Exception('Does not return true'); 20 | } 21 | }); 22 | 23 | it('returns false if the value is not of the correct type', function() { 24 | $matcher = new TypeMatcher('integer'); 25 | 26 | if ($matcher->match('test')) { 27 | throw new \Exception('Does not return false'); 28 | } 29 | }); 30 | }); 31 | 32 | context('getFailureMessage', function() { 33 | it('lists the expected type and the type of the value', function() { 34 | $matcher = new TypeMatcher('integer'); 35 | $matcher->match(false); 36 | $expected = 'Expected integer, got boolean'; 37 | 38 | if ($expected !== $matcher->getFailureMessage()) { 39 | throw new \Exception('Did not return expected failure message'); 40 | } 41 | }); 42 | 43 | it('lists the expected and actual type with negated logic', function() { 44 | $matcher = new TypeMatcher('integer'); 45 | $matcher->match(0); 46 | $expected = 'Expected a type other than integer'; 47 | 48 | if ($expected !== $matcher->getFailureMessage(true)) { 49 | throw new \Exception('Did not return expected failure message'); 50 | } 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /spec/Expectation/Matcher/PatternMatcherSpec.php: -------------------------------------------------------------------------------- 1 | match('TESTing')) { 20 | throw new \Exception('Does not return true'); 21 | } 22 | }); 23 | 24 | it('returns false subject does not match the pattern', function() { 25 | $matcher = new PatternMatcher('/test\w{3}/'); 26 | 27 | if ($matcher->match('TESTing')) { 28 | throw new \Exception('Does not return false'); 29 | } 30 | }); 31 | }); 32 | 33 | context('getFailureMessage', function() { 34 | it('lists the expected pattern', function() { 35 | $matcher = new PatternMatcher('/\w*/'); 36 | $matcher->match('123'); 37 | $expected = 'Expected "123" to match /\w*/'; 38 | 39 | if ($expected !== $matcher->getFailureMessage()) { 40 | throw new \Exception('Did not return expected failure message'); 41 | } 42 | }); 43 | 44 | it('lists the expected pattern with negated logic', function() { 45 | $matcher = new PatternMatcher('/\w*/'); 46 | $matcher->match('abc'); 47 | $expected = 'Expected "abc" not to match /\w*/'; 48 | 49 | if ($expected !== $matcher->getFailureMessage(true)) { 50 | throw new \Exception('Did not return expected failure message'); 51 | } 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /spec/Expectation/Matcher/SuffixMatcherSpec.php: -------------------------------------------------------------------------------- 1 | match('test123')) { 20 | throw new \Exception('Does not return true'); 21 | } 22 | }); 23 | 24 | it('returns false if the subject does not contain the suffix', function() { 25 | $matcher = new SuffixMatcher('12'); 26 | 27 | if ($matcher->match('TEST123')) { 28 | throw new \Exception('Does not return false'); 29 | } 30 | }); 31 | }); 32 | 33 | context('getFailureMessage', function() { 34 | it('lists the expected suffix', function() { 35 | $matcher = new SuffixMatcher('test'); 36 | $matcher->match('TEST123'); 37 | $expected = 'Expected "TEST123" to end with "test"'; 38 | 39 | if ($expected !== $matcher->getFailureMessage()) { 40 | throw new \Exception('Did not return expected failure message'); 41 | } 42 | }); 43 | 44 | it('lists the expected suffix with negated logic', function() { 45 | $matcher = new SuffixMatcher('test'); 46 | $matcher->match('test123'); 47 | $expected = 'Expected "test123" not to end with "test"'; 48 | 49 | if ($expected !== $matcher->getFailureMessage(true)) { 50 | throw new \Exception('Did not return expected failure message'); 51 | } 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /spec/Expectation/Matcher/PrefixMatcherSpec.php: -------------------------------------------------------------------------------- 1 | match('test123')) { 20 | throw new \Exception('Does not return true'); 21 | } 22 | }); 23 | 24 | it('returns false if the subject does not contain the prefix', function() { 25 | $matcher = new PrefixMatcher('test'); 26 | 27 | if ($matcher->match('TEST123')) { 28 | throw new \Exception('Does not return false'); 29 | } 30 | }); 31 | }); 32 | 33 | context('getFailureMessage', function() { 34 | it('lists the expected prefix', function() { 35 | $matcher = new PrefixMatcher('test'); 36 | $matcher->match('TEST123'); 37 | $expected = 'Expected "TEST123" to start with "test"'; 38 | 39 | if ($expected !== $matcher->getFailureMessage()) { 40 | throw new \Exception('Did not return expected failure message'); 41 | } 42 | }); 43 | 44 | it('lists the expected prefix with negated logic', function() { 45 | $matcher = new PrefixMatcher('test'); 46 | $matcher->match('test123'); 47 | $expected = 'Expected "test123" not to start with "test"'; 48 | 49 | if ($expected !== $matcher->getFailureMessage(true)) { 50 | throw new \Exception('Did not return expected failure message'); 51 | } 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/Expectation/Matcher/LengthMatcher.php: -------------------------------------------------------------------------------- 1 | expected = $expected; 21 | } 22 | 23 | /** 24 | * Compares the length of the given array or string to the expected value. 25 | * Returns true if the value is of the expected length, else false. 26 | * 27 | * @param mixed $actual An array or string for which to test the length 28 | * @return boolean Whether or not the value is of the expected length 29 | * @throws \Exception If $actual isn't of type array or string 30 | */ 31 | public function match($actual) 32 | { 33 | if (is_string($actual)) { 34 | $this->type = 'string'; 35 | $this->actual = strlen($actual); 36 | } elseif (is_array($actual)) { 37 | $this->type = 'array'; 38 | $this->actual = count($actual); 39 | } else { 40 | throw new \Exception('LengthMatcher::match() requires an array or string'); 41 | } 42 | 43 | return ($this->actual === $this->expected); 44 | } 45 | 46 | /** 47 | * Returns an error message indicating why the match failed, and the 48 | * negation of the message if $negated is true. 49 | * 50 | * @param boolean $negated Whether or not to print the negated message 51 | * @return string The error message 52 | */ 53 | public function getFailureMessage($negated = false) 54 | { 55 | if (!$negated) { 56 | return "Expected {$this->type} to have a length of {$this->expected}"; 57 | } else { 58 | return "Expected {$this->type} not to have a length of {$this->expected}"; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /spec/Expectation/Matcher/ArrayKeyMatcherSpec.php: -------------------------------------------------------------------------------- 1 | match(['test' => 'value'])) { 19 | throw new \Exception('Does not return true'); 20 | } 21 | }); 22 | 23 | it('returns false if the key does not exist in the array', function() { 24 | $matcher = new ArraykeyMatcher('value'); 25 | 26 | if ($matcher->match(['test' => 'value'])) { 27 | throw new \Exception('Does not return false'); 28 | } 29 | }); 30 | }); 31 | 32 | context('getFailureMessage', function() { 33 | it('lists the arraykey and actual values', function() { 34 | $matcher = new ArraykeyMatcher('value'); 35 | $matcher->match(['test' => 'value']); 36 | $expected = 'Expected array to have the key "value"'; 37 | 38 | if ($expected !== $matcher->getFailureMessage()) { 39 | throw new \Exception('Did not return expected failure message'); 40 | } 41 | }); 42 | 43 | it('lists the arraykey and actual values with negated logic', function() { 44 | $matcher = new ArraykeyMatcher('test'); 45 | $matcher->match(['test' => 'value']); 46 | $expected = 'Expected array not to have the key "test"'; 47 | 48 | if ($expected !== $matcher->getFailureMessage(true)) { 49 | throw new \Exception('Did not return expected failure message'); 50 | } 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /spec/Expectation/Matcher/InstanceMatcherSpec.php: -------------------------------------------------------------------------------- 1 | match(new stdClass())) { 19 | throw new \Exception('Does not return true'); 20 | } 21 | }); 22 | 23 | it('returns false if the object is not an instance', function() { 24 | $matcher = new InstanceMatcher('Closure'); 25 | 26 | if ($matcher->match(new stdClass())) { 27 | throw new \Exception('Does not return false'); 28 | } 29 | }); 30 | }); 31 | 32 | context('getFailureMessage', function() { 33 | it('lists the expected class and the class of the object', function() { 34 | $matcher = new InstanceMatcher('Closure'); 35 | $matcher->match(new stdClass()); 36 | $expected = 'Expected an instance of Closure, got stdClass'; 37 | 38 | if ($expected !== $matcher->getFailureMessage()) { 39 | throw new \Exception('Did not return expected failure message'); 40 | } 41 | }); 42 | 43 | it('lists the expected type and needle with negated logic', function() { 44 | $matcher = new InstanceMatcher('stdClass'); 45 | $matcher->match(new stdClass()); 46 | $expected = 'Expected an instance other than stdClass'; 47 | 48 | if ($expected !== $matcher->getFailureMessage(true)) { 49 | throw new \Exception('Did not return expected failure message'); 50 | } 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /spec/Expectation/Matcher/PrintMatcherSpec.php: -------------------------------------------------------------------------------- 1 | match($callable)) { 23 | throw new \Exception('Does not return true'); 24 | } 25 | }); 26 | 27 | it('returns false if the callable did not print the output', function() { 28 | $callable = function() { 29 | echo 'testing' . PHP_EOL; 30 | }; 31 | 32 | $matcher = new PrintMatcher('testing'); 33 | if ($matcher->match($callable)) { 34 | throw new \Exception('Does not return false'); 35 | } 36 | }); 37 | }); 38 | 39 | context('getFailureMessage', function() { 40 | it('lists the expected and resulting output', function() { 41 | $matcher = new PrintMatcher('testing'); 42 | $matcher->match(function() { 43 | echo 'test'; 44 | }); 45 | 46 | $expected = 'Expected "testing", got "test"'; 47 | if ($expected !== $matcher->getFailureMessage()) { 48 | throw new \Exception('Did not return expected failure message'); 49 | } 50 | }); 51 | 52 | it('lists expected and resulting output with negated logic', function() { 53 | $matcher = new PrintMatcher('testing'); 54 | $matcher->match(function() { 55 | echo 'testing'; 56 | }); 57 | 58 | $expected = 'Expected output other than "testing"'; 59 | if ($expected !== $matcher->getFailureMessage(true)) { 60 | throw new \Exception('Did not return expected failure message'); 61 | } 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/Reporter/ListReporter.php: -------------------------------------------------------------------------------- 1 | depth = 0; 23 | } 24 | 25 | /** 26 | * Ran before the containing test suite is invoked. 27 | * 28 | * @param Suite $suite The test suite before which to run this method 29 | */ 30 | public function beforeSuite(Suite $suite) 31 | { 32 | if ($this->depth == 0) { 33 | $this->console->writeLn(''); 34 | } 35 | 36 | $this->depth += 1; 37 | parent::beforeSuite($suite); 38 | } 39 | 40 | /** 41 | * Ran after the containing test suite is invoked. 42 | * 43 | * @param Suite $suite The test suite after which to run this method 44 | */ 45 | public function afterSuite(Suite $suite) 46 | { 47 | $this->depth -= 1; 48 | parent::afterSuite($suite); 49 | } 50 | 51 | /** 52 | * Ran after an individual spec. May be used to display the results of that 53 | * particular spec. 54 | * 55 | * @param Spec $spec The spec after which to run this method 56 | */ 57 | public function afterSpec(Spec $spec) 58 | { 59 | if ($spec->isFailed()) { 60 | $this->failures[] = $spec; 61 | $title = $this->formatter->red($spec); 62 | } elseif ($spec->isIncomplete()) { 63 | $this->incompleteSpecs[] = $spec; 64 | $title = $this->formatter->cyan($spec); 65 | } elseif ($spec->isPending()) { 66 | $this->pendingSpecs[] = $spec; 67 | $title = $this->formatter->yellow($spec); 68 | } else { 69 | $title = $this->formatter->grey($spec); 70 | } 71 | 72 | $this->console->writeLn($title); 73 | } 74 | 75 | /** 76 | * If a given hook failed, adds it to list of failures and prints the 77 | * result. 78 | * 79 | * @param Hook $hook The failed hook 80 | */ 81 | protected function handleHookFailure(Hook $hook) 82 | { 83 | $this->failures[] = $hook; 84 | $title = $this->formatter->red($hook); 85 | $this->console->writeLn($title); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /spec/Console/ConsoleOptionSpec.php: -------------------------------------------------------------------------------- 1 | 'testLongName', 8 | 'shortName' => 'testShortName', 9 | 'description' => 'testDescription', 10 | 'argumentName' => 'testArgumentName', 11 | ]; 12 | 13 | $option = new ConsoleOption($optInfo['longName'], $optInfo['shortName'], 14 | $optInfo['description'], $optInfo['argumentName']); 15 | 16 | context('basic getters', function() use ($option, $optInfo) { 17 | it('return longName', function() use ($option, $optInfo) { 18 | expect($option->getLongName())->toBe($optInfo['longName']); 19 | }); 20 | 21 | it('return shortName', function() use ($option, $optInfo) { 22 | expect($option->getShortName())->toBe($optInfo['shortName']); 23 | }); 24 | 25 | it('return description', function() use ($option, $optInfo) { 26 | expect($option->getDescription())->toBe($optInfo['description']); 27 | }); 28 | 29 | it('return argumentName', function() use ($option, $optInfo) { 30 | expect($option->getArgumentName())->toBe($optInfo['argumentName']); 31 | }); 32 | 33 | it('return value', function() use ($option, $optInfo) { 34 | expect($option->getValue())->toBe(false); 35 | }); 36 | }); 37 | 38 | context('acceptArguments', function() { 39 | it('returns true if an argument name was defined', function () { 40 | $option = new ConsoleOption('sname', 'lname', 'desc', 'argname'); 41 | expect($option->acceptsArguments())->toBe(true); 42 | }); 43 | 44 | it('returns true if an argument name was not defined', function () { 45 | $option = new ConsoleOption('sname', 'lname', 'desc'); 46 | expect($option->acceptsArguments())->toBe(false); 47 | }); 48 | }); 49 | 50 | context('setValue', function() { 51 | it('sets the value if the option accepts arguments', function() { 52 | $option = new ConsoleOption('sname', 'lname', 'desc', 'argname'); 53 | $value = 'test'; 54 | $option->setValue($value); 55 | expect($option->getValue())->toBe($value); 56 | }); 57 | 58 | it('casts the value to boolean if the option does not', function() { 59 | $option = new ConsoleOption('sname', 'lname', 'desc'); 60 | $value = 'test'; 61 | $option->setValue($value); 62 | expect($option->getValue())->toBe(true); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/Reporter/DotReporter.php: -------------------------------------------------------------------------------- 1 | lineLength = 0; 25 | } 26 | 27 | /** 28 | * Ran prior to test suite execution to print a new line. 29 | */ 30 | public function beforeRun() 31 | { 32 | $this->console->writeLn(''); 33 | } 34 | 35 | /** 36 | * Ran before an individual spec. 37 | * 38 | * @param Spec $spec The spec before which to run this method 39 | */ 40 | public function beforeSpec(Spec $spec) 41 | { 42 | parent::beforeSpec($spec); 43 | 44 | if ($this->lineLength == self::$maxPerLine) { 45 | $this->console->writeLn(''); 46 | $this->lineLength = 0; 47 | } 48 | } 49 | 50 | /** 51 | * Ran after an individual spec. 52 | * 53 | * @param Spec $spec The spec after which to run this method 54 | */ 55 | public function afterSpec(Spec $spec) 56 | { 57 | $this->lineLength += 1; 58 | 59 | if ($spec->isFailed()) { 60 | $this->failures[] = $spec; 61 | $failure = $this->formatter->red('F'); 62 | $this->console->write($failure); 63 | } elseif ($spec->isIncomplete()) { 64 | $this->incompleteSpecs[] = $spec; 65 | $incomplete = $this->formatter->cyan('I'); 66 | $this->console->write($incomplete); 67 | } elseif ($spec->isPending()) { 68 | $this->pendingSpecs[] = $spec; 69 | $pending = $this->formatter->yellow('P'); 70 | $this->console->write($pending); 71 | } else { 72 | $this->console->write('.'); 73 | } 74 | } 75 | 76 | /** 77 | * Invoked after the test suite has ran, allowing for the display of test 78 | * results and related statistics. 79 | */ 80 | public function afterRun() 81 | { 82 | $this->console->writeLn(''); 83 | parent::afterRun(); 84 | } 85 | 86 | /** 87 | * If a given hook failed, adds it to list of failures and prints the 88 | * result. 89 | * 90 | * @param Hook $hook The failed hook 91 | */ 92 | protected function handleHookFailure(Hook $hook) 93 | { 94 | $this->lineLength += 1; 95 | $this->failures[] = $hook; 96 | $failure = $this->formatter->red('F'); 97 | $this->console->write($failure); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Runnable/Runnable.php: -------------------------------------------------------------------------------- 1 | closure)) { 26 | // Set the error handler for the spec 27 | set_error_handler([$this, 'handleError'], E_ALL); 28 | 29 | // Invoke the closure while catching exceptions 30 | try { 31 | $this->closure->__invoke(); 32 | } catch (ExpectationException $exception) { 33 | $this->exception = $exception; 34 | } catch (\Exception $exception) { 35 | $this->handleException($exception); 36 | } 37 | 38 | restore_error_handler(); 39 | } 40 | } 41 | 42 | /** 43 | * Returns the title of the spec. 44 | * 45 | * @return string The title 46 | */ 47 | public function getTitle() 48 | { 49 | return $this->title; 50 | } 51 | 52 | /** 53 | * Returns the exception of the spec. 54 | * 55 | * @return \Exception The exception 56 | */ 57 | public function getException() 58 | { 59 | return $this->exception; 60 | } 61 | 62 | /** 63 | * An error handler to be used by set_error_handler(). Creates a custom 64 | * ErrorException, and sets the objects $exception property. 65 | * 66 | * @param int $level The error level corresponding to the PHP error 67 | * @param string $string Error message itself 68 | * @param string $file The name of the file from which the error was raised 69 | * @param int $line The line number from which the error was raised 70 | */ 71 | public function handleError($level, $string, $file = null, $line = null) 72 | { 73 | $this->exception = new ErrorException($level, $string, $file, $line); 74 | } 75 | 76 | /** 77 | * An exception handler to be used when calling Runnable::run(). Creates a 78 | * custom RunnableException, corresponding to an uncaught exception in a 79 | * test, and sets the objects $exception property. 80 | * 81 | * @param \Exception $exception The uncaught exception 82 | */ 83 | public function handleException(\Exception $exception) 84 | { 85 | $this->exception = new RunnableException($exception); 86 | } 87 | 88 | /** 89 | * Returns a string containing the spec's name, preceeded by the names of 90 | * all parent suites. 91 | * 92 | * @return string A human readable description of the spec 93 | */ 94 | public function __toString() 95 | { 96 | return "{$this->suite} {$this->title}"; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Reporter/SpecReporter.php: -------------------------------------------------------------------------------- 1 | depth = 0; 26 | } 27 | 28 | /** 29 | * Ran before the containing test suite is invoked. 30 | * 31 | * @param Suite $suite The test suite before which to run this method 32 | */ 33 | public function beforeSuite(Suite $suite) 34 | { 35 | if ($this->depth == 0) { 36 | $this->console->writeLn(''); 37 | } 38 | 39 | $leftPad = str_repeat(' ', self::TAB_SIZE * $this->depth); 40 | $title = $suite->getTitle(); 41 | $this->console->writeLn($leftPad . $title); 42 | 43 | $this->depth += 1; 44 | parent::beforeSuite($suite); 45 | } 46 | 47 | /** 48 | * Ran after the containing test suite is invoked. 49 | * 50 | * @param Suite $suite The test suite after which to run this method 51 | */ 52 | public function afterSuite(Suite $suite) 53 | { 54 | $this->depth -= 1; 55 | parent::afterSuite($suite); 56 | } 57 | 58 | /** 59 | * Ran after an individual spec. May be used to display the results of that 60 | * particular spec. 61 | * 62 | * @param Spec $spec The spec after which to run this method 63 | */ 64 | public function afterSpec(Spec $spec) 65 | { 66 | $leftPad = str_repeat(' ', self::TAB_SIZE * $this->depth); 67 | 68 | if ($spec->isFailed()) { 69 | $this->failures[] = $spec; 70 | $title = $this->formatter->red($spec->getTitle()); 71 | } elseif ($spec->isIncomplete()) { 72 | $this->incompleteSpecs[] = $spec; 73 | $title = $this->formatter->cyan($spec->getTitle()); 74 | } elseif ($spec->isPending()) { 75 | $this->pendingSpecs[] = $spec; 76 | $title = $this->formatter->yellow($spec->getTitle()); 77 | } else { 78 | $title = $this->formatter->grey($spec->getTitle()); 79 | } 80 | 81 | $this->console->writeLn($leftPad . $title); 82 | } 83 | 84 | /** 85 | * If a given hook failed, adds it to list of failures and prints the 86 | * result. 87 | * 88 | * @param Hook $hook The failed hook 89 | */ 90 | protected function handleHookFailure(Hook $hook) 91 | { 92 | $this->failures[] = $hook; 93 | $title = $this->formatter->red($hook->getTitle()); 94 | $leftPad = str_repeat(' ', self::TAB_SIZE * $this->depth); 95 | $this->console->writeLn($leftPad . $title); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /spec/Expectation/Matcher/LengthMatcherSpec.php: -------------------------------------------------------------------------------- 1 | match('pho')) { 19 | throw new \Exception('Does not return true'); 20 | } 21 | }); 22 | 23 | it("returns false if the string doesn't have the given length", function() { 24 | $matcher = new LengthMatcher(5); 25 | if ($matcher->match('Test')) { 26 | throw new \Exception('Does not return false'); 27 | } 28 | }); 29 | 30 | it('returns true if the array has the expected length', function() { 31 | $matcher = new LengthMatcher(2); 32 | if (!$matcher->match(['a', 'b'])) { 33 | throw new \Exception('Does not return true'); 34 | } 35 | }); 36 | 37 | it("returns false if the array doesn't have the expected length", function() { 38 | $matcher = new LengthMatcher(3); 39 | if ($matcher->match(['a', 'b'])) { 40 | throw new \Exception('Does not return false'); 41 | } 42 | }); 43 | 44 | it('throws an exception if not an array or string', function() { 45 | $exceptionThrown = false; 46 | try { 47 | $matcher = new LengthMatcher(2); 48 | $matcher->match(1); 49 | } catch (\Exception $exception) { 50 | $exceptionThrown = true; 51 | } 52 | 53 | if (!$exceptionThrown) { 54 | throw new \Exception('Does not throw an exception'); 55 | } 56 | }); 57 | }); 58 | 59 | context('getFailureMessage', function() { 60 | it('lists the expected length', function() { 61 | $matcher = new LengthMatcher(2); 62 | $matcher->match('pho'); 63 | $expected = 'Expected string to have a length of 2'; 64 | 65 | if ($expected !== $matcher->getFailureMessage()) { 66 | throw new \Exception('Did not return expected failure message'); 67 | } 68 | }); 69 | 70 | it('lists the expected length with negated logic', function() { 71 | $matcher = new LengthMatcher(3); 72 | $matcher->match(['a', 'b', 'c']); 73 | $expected = 'Expected array not to have a length of 3'; 74 | 75 | if ($expected !== $matcher->getFailureMessage(true)) { 76 | throw new \Exception('Did not return expected failure message'); 77 | } 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/Expectation/Matcher/InclusionMatcher.php: -------------------------------------------------------------------------------- 1 | needles = $needles; 30 | $this->matchAll = $matchAll; 31 | } 32 | 33 | /** 34 | * Checks whether or not the needles are found in the supplied $haystack. 35 | * Returns true if the needles are found, false otherwise. 36 | * 37 | * @param mixed $haystack An array or string through which to search 38 | * @return boolean Whether or not the needles were found 39 | * @throws \InvalidArgumentException If $haystack isn't an array or string 40 | */ 41 | public function match($haystack) 42 | { 43 | $this->found = []; 44 | $this->missing = []; 45 | 46 | if (!is_string($haystack) && !is_array($haystack)) { 47 | throw new \InvalidArgumentException('Argument must be an array or string'); 48 | } 49 | 50 | if (is_string($haystack)) { 51 | $this->type = 'string'; 52 | foreach ($this->needles as $needle) { 53 | if (strpos($haystack, $needle) !== false) { 54 | $this->found[] = $needle; 55 | } else { 56 | $this->missing[] = $needle; 57 | } 58 | } 59 | } elseif (is_array($haystack)) { 60 | $this->type = 'array'; 61 | foreach ($this->needles as $needle) { 62 | if (in_array($needle, $haystack)) { 63 | $this->found[] = $needle; 64 | } else { 65 | $this->missing[] = $needle; 66 | } 67 | } 68 | } 69 | 70 | if ($this->matchAll) { 71 | return (count($this->missing) === 0); 72 | } 73 | 74 | return (count($this->found) > 0); 75 | } 76 | 77 | /** 78 | * Returns an error message indicating why the match failed, and the 79 | * negation of the message if $negated is true. 80 | * 81 | * @param boolean $negated Whether or not to print the negated message 82 | * @return string The error message 83 | */ 84 | public function getFailureMessage($negated = false) 85 | { 86 | if (!$negated) { 87 | $missing = implode(', ', $this->missing); 88 | return "Expected {$this->type} to contain {$missing}"; 89 | } else { 90 | $found = implode(', ', $this->found); 91 | return "Expected {$this->type} not to contain {$found}"; 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Console/ConsoleOption.php: -------------------------------------------------------------------------------- 1 | shortName = $shortName; 34 | $this->longName = $longName; 35 | 36 | $this->description = $description; 37 | $this->argumentName = $argumentName; 38 | 39 | $this->value = false; 40 | } 41 | 42 | /** 43 | * Returns the long name (-abcd) of the option. 44 | */ 45 | public function getLongName() 46 | { 47 | return $this->longName; 48 | } 49 | 50 | /** 51 | * Returns the short name (-a) of the option. 52 | */ 53 | public function getShortName() 54 | { 55 | return $this->shortName; 56 | } 57 | 58 | /** 59 | * Returns the description of the option. 60 | */ 61 | public function getDescription() 62 | { 63 | return $this->description; 64 | } 65 | 66 | /** 67 | * Returns the argument name of the option. 68 | */ 69 | public function getArgumentName() 70 | { 71 | return $this->argumentName; 72 | } 73 | 74 | /** 75 | * Returns the value of the option. 76 | */ 77 | public function getValue() 78 | { 79 | return $this->value; 80 | } 81 | 82 | /** 83 | * Sets the value of the option. If the option accepts arguments, the 84 | * supplied value can be of any type. Otherwise, the value is cast as a 85 | * boolean. 86 | * 87 | * @param mixed $value The value to assign to the option 88 | */ 89 | public function setValue($value) 90 | { 91 | if ($this->acceptsArguments()) { 92 | $this->value = $value; 93 | } else { 94 | $this->value = (boolean) $value; 95 | } 96 | } 97 | 98 | /** 99 | * Returns true if the ConsoleOption accepts arguments, as indicated 100 | * by the presence of an argument name, and false otherwise. 101 | * 102 | * @return boolean Whether or no the option accepts arguments 103 | */ 104 | public function acceptsArguments() 105 | { 106 | return ($this->argumentName !== null); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /spec/Console/ConsoleFormatterSpec.php: -------------------------------------------------------------------------------- 1 | alignText($multiArray))->toEqual($aligned); 17 | }); 18 | 19 | it('can use delimiters between columns', function() use ($formatter) { 20 | $multiArray = [ 21 | ['pho', 'b', 'c'], 22 | ['a', 'test'] 23 | ]; 24 | 25 | $aligned = [ 26 | 'pho | b | c', 27 | 'a | test' 28 | ]; 29 | 30 | expect($formatter->alignText($multiArray, ' | '))->toEqual($aligned); 31 | }); 32 | }); 33 | 34 | context('calls to applyForeground', function() use ($formatter) { 35 | it('can set the color black', function() use ($formatter) { 36 | $formattedText = $formatter->black('test'); 37 | expect($formattedText)->toEqual("\033[30mtest\033[0m"); 38 | }); 39 | 40 | it('can set the color red', function() use ($formatter) { 41 | $formattedText = $formatter->red('test'); 42 | expect($formattedText)->toEqual("\033[31mtest\033[0m"); 43 | }); 44 | 45 | it('can set the color green', function() use ($formatter) { 46 | $formattedText = $formatter->green('test'); 47 | expect($formattedText)->toEqual("\033[32mtest\033[0m"); 48 | }); 49 | 50 | it('can set the color cyan', function() use ($formatter) { 51 | $formattedText = $formatter->cyan('test'); 52 | expect($formattedText)->toEqual("\033[36mtest\033[0m"); 53 | }); 54 | 55 | it('can set the color yellow', function() use ($formatter) { 56 | $formattedText = $formatter->yellow('test'); 57 | expect($formattedText)->toEqual("\033[33mtest\033[0m"); 58 | }); 59 | 60 | it('can set the color white', function() use ($formatter) { 61 | $formattedText = $formatter->white('test'); 62 | expect($formattedText)->toEqual("\033[37mtest\033[0m"); 63 | }); 64 | }); 65 | 66 | context('calls to applyStyle', function() use ($formatter) { 67 | it('can set the text bold', function() use ($formatter) { 68 | $formattedText = $formatter->bold('test'); 69 | expect($formattedText)->toEqual("\x1b[1mtest\x1b[22m"); 70 | }); 71 | 72 | it('can set the text italic', function() use ($formatter) { 73 | $formattedText = $formatter->italic('test'); 74 | expect($formattedText)->toEqual("\x1b[3mtest\x1b[23m"); 75 | }); 76 | }); 77 | 78 | context('disableANSI', function() { 79 | it('disables formatting using ANSI escape codes', function() { 80 | $str = 'test'; 81 | $formatter = new ConsoleFormatter; 82 | $formatter->disableANSI(); 83 | 84 | expect($formatter->green($str))->toEqual($str); 85 | expect($formatter->italic($str))->toEqual($str); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /src/Runnable/Spec.php: -------------------------------------------------------------------------------- 1 | title = $title; 32 | $this->suite = $suite; 33 | 34 | if ($closure) { 35 | $this->closure = $closure->bindTo($suite); 36 | } 37 | } 38 | 39 | /** 40 | * Mark a Spec as pending. 41 | */ 42 | public function setPending() 43 | { 44 | $this->pending = true; 45 | } 46 | 47 | /** 48 | * Invokes Runnable::run(), storing any exception in the corresponding 49 | * property, followed by setting the specs' result. 50 | */ 51 | public function run() 52 | { 53 | if (true === $this->pending) { 54 | $this->result = self::PENDING; 55 | } else { 56 | parent::run(); 57 | 58 | if ($this->closure && !$this->exception) { 59 | $this->result = self::PASSED; 60 | } elseif ($this->closure) { 61 | $this->result = self::FAILED; 62 | } else { 63 | $this->result = self::INCOMPLETE; 64 | } 65 | } 66 | } 67 | 68 | /** 69 | * Returns the result of the spec, which after running, is one of PASSED, 70 | * FAILED, or INCOMPLETE. 71 | */ 72 | public function getResult() 73 | { 74 | return $this->result; 75 | } 76 | 77 | /** 78 | * Return true if that passes the spec 79 | * 80 | * @return bool 81 | */ 82 | public function isPassed() 83 | { 84 | return $this->getResult() === self::PASSED; 85 | } 86 | 87 | /** 88 | * Return true if that is failing the spec 89 | * 90 | * @return bool 91 | */ 92 | public function isFailed() 93 | { 94 | return $this->getResult() === self::FAILED; 95 | } 96 | 97 | /** 98 | * Return true if the incomplete 99 | * 100 | * @return bool 101 | */ 102 | public function isIncomplete() 103 | { 104 | return $this->getResult() === self::INCOMPLETE; 105 | } 106 | 107 | /** 108 | * Return true if the pending 109 | * 110 | * @return bool 111 | */ 112 | public function isPending() 113 | { 114 | return $this->pending === true; 115 | } 116 | 117 | /** 118 | * Sets the spec exception, also marking it as failed. 119 | * 120 | * @param \Exception The exception 121 | */ 122 | public function setException(\Exception $exception = null) 123 | { 124 | $this->exception = $exception; 125 | $this->result = self::FAILED; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /spec/Suite/SuiteSpec.php: -------------------------------------------------------------------------------- 1 | toEqual('Parent'); 13 | }); 14 | 15 | it('is preceded by the parent title, if set', function() use ($child) { 16 | expect((string) $child)->toEqual('Parent Child'); 17 | }); 18 | }); 19 | 20 | context('__set', function() use ($child, $parent) { 21 | it('sets a key value pair for the given suite', function() use ($parent) { 22 | $parent->key1 = 'parentValue'; 23 | expect($parent->key1)->toEqual('parentValue'); 24 | }); 25 | 26 | it('does not modify the parent suite', function() use ($parent, $child) { 27 | $parent->key2 = 'parentValue'; 28 | $child->key2 = 'childValue'; 29 | 30 | expect($child->key2)->toEqual('childValue'); 31 | expect($parent->key2)->toEqual('parentValue'); 32 | }); 33 | 34 | it('throws an exception if it conflicts with a method', function() use ($child) { 35 | $overwriteAttempt = function() { 36 | $this->addSpec = 'should fail'; 37 | }; 38 | 39 | expect($overwriteAttempt)->toThrow('\Exception'); 40 | }); 41 | }); 42 | 43 | context('__get', function() use ($child, $parent) { 44 | it('returns the stored value', function() use ($parent) { 45 | expect($parent->key1)->toEqual('parentValue'); 46 | }); 47 | 48 | it("if not set, returns the parent's value", function() use ($child) { 49 | expect($child->key1)->toEqual('parentValue'); 50 | }); 51 | 52 | it('returns null if not found in parents', function() use ($child) { 53 | expect($child->randomkey)->toBe(null); 54 | }); 55 | }); 56 | 57 | context('__call', function() use ($child, $parent) { 58 | $parent->callable1 = function() { 59 | return 'Callable 1'; 60 | }; 61 | 62 | $child->callable2 = function() { 63 | return 'Callable 2'; 64 | }; 65 | 66 | $child->callableWithArgs = function($arg1, $arg2) { 67 | return "$arg1 $arg2"; 68 | }; 69 | 70 | it('invokes the stored callable', function() use ($child) { 71 | expect($child->callable2())->toBe('Callable 2'); 72 | }); 73 | 74 | it('invokes the stored callable with arguments', function() use ($child) { 75 | $invokeCallable = function() use ($child) { 76 | return $child->callableWithArgs('test', 'callable'); 77 | }; 78 | 79 | expect($invokeCallable())->toBe('test callable'); 80 | }); 81 | 82 | it("if not set, invokes the parent's callable", function() use ($child) { 83 | expect($child->callable1())->toBe('Callable 1'); 84 | }); 85 | 86 | it('throws an exception if not found in the parent', function() use ($child) { 87 | $callInvalidKey = function() use ($child) { 88 | $child->invalidKey(); 89 | }; 90 | 91 | expect($callInvalidKey)->toThrow('\BadFunctionCallException'); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /spec/Reporter/ListReporterSpec.php: -------------------------------------------------------------------------------- 1 | parseArguments(); 13 | $this->console = $console; 14 | 15 | $suite = new Suite('test', function(){}); 16 | $spec = new Spec('testspec', function(){}, $suite); 17 | $this->spec = $spec; 18 | }); 19 | 20 | it('implements the ReporterInterface', function() { 21 | $reporter = new ListReporter($this->console); 22 | expect($reporter instanceof ReporterInterface)->toBe(true); 23 | }); 24 | 25 | context('beforeSpec', function() { 26 | it('increments the spec count', function() { 27 | $reporter = new ListReporter($this->console); 28 | 29 | $countBefore = $reporter->getSpecCount(); 30 | $reporter->beforeSpec($this->spec); 31 | $countAfter = $reporter->getSpecCount(); 32 | 33 | expect($countAfter)->toEqual($countBefore + 1); 34 | }); 35 | }); 36 | 37 | context('afterSpec', function() { 38 | it('prints the full spec string in grey if it passed', function() { 39 | $reporter = new ListReporter($this->console); 40 | $afterSpec = function() use ($reporter) { 41 | $reporter->afterSpec($this->spec); 42 | }; 43 | 44 | $console = $this->console; 45 | $title = $this->console->formatter->grey($this->spec); 46 | expect($afterSpec)->toPrint($title . PHP_EOL); 47 | }); 48 | 49 | it('prints the full spec string in red if it failed', function() { 50 | $suite = new Suite('test', function(){}); 51 | $spec = new Spec('testspec', function() { 52 | throw new \Exception('test'); 53 | }, $suite); 54 | $spec->run(); 55 | 56 | $afterSpec = function() use ($spec) { 57 | $reporter = new ListReporter($this->console); 58 | $reporter->afterSpec($spec); 59 | }; 60 | 61 | $console = $this->console; 62 | $specTitle = $console->formatter->red($this->spec); 63 | expect($afterSpec)->toPrint($specTitle . PHP_EOL); 64 | }); 65 | 66 | it('prints the full spec string in cyan if incomplete', function() { 67 | $suite = new Suite('test', function(){}); 68 | $spec = new Spec('testspec', null, $suite); 69 | $spec->run(); 70 | 71 | $afterSpec = function() use ($spec) { 72 | $reporter = new ListReporter($this->console); 73 | $reporter->afterSpec($spec); 74 | }; 75 | 76 | $console = $this->console; 77 | $specTitle = $console->formatter->cyan($this->spec); 78 | expect($afterSpec)->toPrint($specTitle . PHP_EOL); 79 | }); 80 | 81 | it('prints the full spec string in yellow if pending', function() { 82 | $suite = new Suite('test', function(){}); 83 | $spec = new Spec('testspec', null, $suite); 84 | $spec->setPending(); 85 | $spec->run(); 86 | 87 | $afterSpec = function() use ($spec) { 88 | $reporter = new ListReporter($this->console); 89 | $reporter->afterSpec($spec); 90 | }; 91 | 92 | $console = $this->console; 93 | $specTitle = $console->formatter->yellow($this->spec); 94 | expect($afterSpec)->toPrint($specTitle . PHP_EOL); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /spec/Expectation/Matcher/ExceptionMatcherSpec.php: -------------------------------------------------------------------------------- 1 | match($callable)) { 23 | throw new \Exception('Does not return true'); 24 | } 25 | }); 26 | 27 | it('returns false if the callable threw a different exception', function() { 28 | $callable = function() { 29 | throw new pho\Exception\ErrorException(E_ERROR, 'test'); 30 | }; 31 | 32 | $matcher = new ExceptionMatcher('ErrorException'); 33 | if ($matcher->match($callable)) { 34 | throw new \Exception('Does not return false'); 35 | } 36 | }); 37 | 38 | it('returns false if the callable did not throw an exception', function() { 39 | $callable = function() { 40 | return; 41 | }; 42 | 43 | $matcher = new ExceptionMatcher('RunnablException'); 44 | if ($matcher->match($callable)) { 45 | throw new \Exception('Does not return false'); 46 | } 47 | }); 48 | }); 49 | 50 | context('getFailureMessage', function() { 51 | it('lists the expected and thrown exception', function() { 52 | $callable = function() { 53 | throw new pho\Exception\ErrorException(E_ERROR, 'test'); 54 | }; 55 | 56 | $matcher = new ExceptionMatcher('pho\Exception\RunnableException'); 57 | $matcher->match($callable); 58 | 59 | $expected = 'Expected pho\Exception\RunnableException to be ' . 60 | 'thrown, got pho\Exception\ErrorException'; 61 | if ($expected !== $matcher->getFailureMessage()) { 62 | throw new \Exception('Did not return expected failure message'); 63 | } 64 | }); 65 | 66 | it('lists the expected exception if none thrown', function() { 67 | $callable = function() { 68 | return; 69 | }; 70 | 71 | $matcher = new ExceptionMatcher('pho\Exception\RunnableException'); 72 | $matcher->match($callable); 73 | 74 | $expected = 'Expected pho\Exception\RunnableException to be ' . 75 | 'thrown, none thrown'; 76 | if ($expected !== $matcher->getFailureMessage()) { 77 | throw new \Exception('Did not return expected failure message'); 78 | } 79 | }); 80 | 81 | it('lists expected and resulting output with negated logic', function() { 82 | $callable = function() { 83 | throw new pho\Exception\ErrorException(E_ERROR, 'test'); 84 | }; 85 | 86 | $matcher = new ExceptionMatcher('pho\Exception\ErrorException'); 87 | $matcher->match($callable); 88 | 89 | $expected = 'Expected pho\Exception\ErrorException not to be thrown'; 90 | if ($expected !== $matcher->getFailureMessage(true)) { 91 | throw new \Exception('Did not return expected failure message'); 92 | } 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /spec/Reporter/DotReporterSpec.php: -------------------------------------------------------------------------------- 1 | parseArguments(); 13 | $this->console = $console; 14 | 15 | $reporter = new DotReporter($console); 16 | $this->reporter = $reporter; 17 | 18 | $suite = new Suite('test', function(){}); 19 | $spec = new Spec('testspec', function(){}, $suite); 20 | $this->spec = $spec; 21 | }); 22 | 23 | it('implements the ReporterInterface', function() { 24 | expect($this->reporter instanceof ReporterInterface)->toBe(true); 25 | }); 26 | 27 | context('beforeSpec', function() { 28 | it('increments the spec count', function() { 29 | $reporter = $this->reporter; 30 | 31 | $countBefore = $reporter->getSpecCount(); 32 | $reporter->beforeSpec($this->spec); 33 | $countAfter = $reporter->getSpecCount(); 34 | 35 | expect($countAfter)->toEqual($countBefore + 1); 36 | }); 37 | 38 | it('prints a newline after a limit', function() { 39 | $print = function() { 40 | $reporter = $this->reporter; 41 | $spec = $this->spec; 42 | 43 | for ($i = 0; $i <= 60; $i++) { 44 | $reporter->beforeSpec($spec); 45 | $reporter->afterSpec($spec); 46 | } 47 | }; 48 | 49 | // TODO: Add pattern matching to toPrint, use '/.*\n/' 50 | $expected = '...................................................' . 51 | '.........' . PHP_EOL . '.'; 52 | expect($print)->toPrint($expected); 53 | }); 54 | }); 55 | 56 | context('afterSpec', function() { 57 | it('prints a dot if the spec passed', function() { 58 | $reporter = $this->reporter; 59 | $afterSpec = function() { 60 | $this->reporter->afterSpec($this->spec); 61 | }; 62 | 63 | expect($afterSpec)->toPrint('.'); 64 | }); 65 | 66 | it('prints an F in red if a spec failed', function() { 67 | $suite = new Suite('test', function(){}); 68 | $spec = new Spec('testspec', function() { 69 | throw new \Exception('test'); 70 | }, $suite); 71 | $spec->run(); 72 | 73 | $afterSpec = function() use ($spec) { 74 | $this->reporter->afterSpec($spec); 75 | }; 76 | 77 | $console = $this->console; 78 | expect($afterSpec)->toPrint($console->formatter->red('F')); 79 | }); 80 | 81 | it('prints an I in cyan if incomplete', function() { 82 | $suite = new Suite('test', function(){}); 83 | $spec = new Spec('testspec', null, $suite); 84 | $spec->run(); 85 | 86 | $afterSpec = function() use ($spec) { 87 | $this->reporter->afterSpec($spec); 88 | }; 89 | 90 | $console = $this->console; 91 | expect($afterSpec)->toPrint($console->formatter->cyan('I')); 92 | }); 93 | 94 | it('prints an P in yellow if pending', function() { 95 | $suite = new Suite('test', function(){}); 96 | $spec = new Spec('testspec', null, $suite); 97 | $spec->setPending(); 98 | $spec->run(); 99 | 100 | $afterSpec = function() use ($spec) { 101 | $this->reporter->afterSpec($spec); 102 | }; 103 | 104 | $console = $this->console; 105 | expect($afterSpec)->toPrint($console->formatter->yellow('P')); 106 | }); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /src/globalPho.php: -------------------------------------------------------------------------------- 1 | parseArguments(); 13 | $this->console = $console; 14 | 15 | $suite = new Suite('test', function(){}); 16 | $spec = new Spec('testspec', function(){}, $suite); 17 | $this->spec = $spec; 18 | }); 19 | 20 | it('implements the ReporterInterface', function() { 21 | $reporter = new SpecReporter($this->console); 22 | expect($reporter instanceof ReporterInterface)->toBe(true); 23 | }); 24 | 25 | context('beforeSuite', function() { 26 | before(function() { 27 | $reporter = new SpecReporter($this->console); 28 | $this->reporter = $reporter; 29 | }); 30 | 31 | it('prints the suite title', function() { 32 | $beforeSuite = function() { 33 | $suite = new Suite('test suite', function() {}); 34 | $reporter = $this->reporter; 35 | $reporter->beforeSuite($suite); 36 | }; 37 | 38 | expect($beforeSuite)->toPrint(PHP_EOL . "test suite" . PHP_EOL); 39 | }); 40 | 41 | it('pads nested suites', function() { 42 | $beforeSuite = function() { 43 | $suite = new Suite('test suite', function() {}); 44 | $reporter = $this->reporter; 45 | $reporter->beforeSuite($suite); 46 | }; 47 | 48 | expect($beforeSuite)->toPrint(" test suite" . PHP_EOL); 49 | }); 50 | }); 51 | 52 | context('beforeSpec', function() { 53 | it('increments the spec count', function() { 54 | $reporter = new SpecReporter($this->console); 55 | 56 | $countBefore = $reporter->getSpecCount(); 57 | $reporter->beforeSpec($this->spec); 58 | $countAfter = $reporter->getSpecCount(); 59 | 60 | expect($countAfter)->toEqual($countBefore + 1); 61 | }); 62 | }); 63 | 64 | context('afterSpec', function() { 65 | it('prints the spec title in grey if it passed', function() { 66 | $reporter = new SpecReporter($this->console); 67 | $afterSpec = function() use ($reporter) { 68 | $reporter->afterSpec($this->spec); 69 | }; 70 | 71 | $console = $this->console; 72 | $title = $this->console->formatter->grey($this->spec->getTitle()); 73 | expect($afterSpec)->toPrint($title . PHP_EOL); 74 | }); 75 | 76 | it('prints the spec title in red if it failed', function() { 77 | $suite = new Suite('test', function(){}); 78 | $spec = new Spec('testspec', function() { 79 | throw new \Exception('test'); 80 | }, $suite); 81 | $spec->run(); 82 | 83 | $afterSpec = function() use ($spec) { 84 | $reporter = new SpecReporter($this->console); 85 | $reporter->afterSpec($spec); 86 | }; 87 | 88 | $console = $this->console; 89 | $specTitle = $console->formatter->red($this->spec->getTitle()); 90 | expect($afterSpec)->toPrint($specTitle . PHP_EOL); 91 | }); 92 | 93 | it('prints the spec title in cyan if incomplete', function() { 94 | $suite = new Suite('test', function(){}); 95 | $spec = new Spec('testspec', null, $suite); 96 | $spec->run(); 97 | 98 | $afterSpec = function() use ($spec) { 99 | $reporter = new SpecReporter($this->console); 100 | $reporter->afterSpec($spec); 101 | }; 102 | 103 | $console = $this->console; 104 | $specTitle = $console->formatter->cyan($this->spec->getTitle()); 105 | expect($afterSpec)->toPrint($specTitle . PHP_EOL); 106 | }); 107 | 108 | it('prints the spec title in yellow if pending', function() { 109 | $suite = new Suite('test', function(){}); 110 | $spec = new Spec('testspec', null, $suite); 111 | $spec->setPending(); 112 | $spec->run(); 113 | 114 | $afterSpec = function() use ($spec) { 115 | $reporter = new SpecReporter($this->console); 116 | $reporter->afterSpec($spec); 117 | }; 118 | 119 | $console = $this->console; 120 | $specTitle = $console->formatter->yellow($this->spec->getTitle()); 121 | expect($afterSpec)->toPrint($specTitle . PHP_EOL); 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /src/Reporter/AbstractReporter.php: -------------------------------------------------------------------------------- 1 | console = $console; 35 | $this->formatter = $console->formatter; 36 | $this->startTime = microtime(true); 37 | $this->specCount = 0; 38 | $this->failures = []; 39 | $this->incompleteSpecs = []; 40 | $this->pendingSpecs = []; 41 | } 42 | 43 | /** 44 | * Ran prior to test suite execution. 45 | */ 46 | public function beforeRun() 47 | { 48 | // Do nothing 49 | } 50 | 51 | /** 52 | * Returns the number of specs ran by the reporter. 53 | * 54 | * @returns int The number of specs ran 55 | */ 56 | public function getSpecCount() 57 | { 58 | return $this->specCount; 59 | } 60 | 61 | /** 62 | * Ran before the containing test suite is invoked. 63 | * 64 | * @param Suite $suite The test suite before which to run this method 65 | */ 66 | public function beforeSuite(Suite $suite) 67 | { 68 | $hook = $suite->getHook('before'); 69 | if ($hook && $hook->getException()) { 70 | $this->handleHookFailure($hook); 71 | } 72 | } 73 | 74 | /** 75 | * Ran after the containing test suite is invoked. 76 | * 77 | * @param Suite $suite The test suite after which to run this method 78 | */ 79 | public function afterSuite(Suite $suite) 80 | { 81 | $hook = $suite->getHook('after'); 82 | if ($hook && $hook->getException()) { 83 | $this->handleHookFailure($hook); 84 | } 85 | } 86 | 87 | /** 88 | * Ran before an individual spec. 89 | * 90 | * @param Spec $spec The spec before which to run this method 91 | */ 92 | public function beforeSpec(Spec $spec) 93 | { 94 | $this->specCount += 1; 95 | } 96 | 97 | /** 98 | * Invoked after the test suite has ran, allowing for the display of test 99 | * results and related statistics. 100 | */ 101 | public function afterRun() 102 | { 103 | if (count($this->failures)) { 104 | $this->console->writeLn("\nFailures:"); 105 | } 106 | 107 | foreach ($this->failures as $spec) { 108 | $failedText = $this->formatter->red("\n\"$spec\" FAILED"); 109 | $this->console->writeLn($failedText); 110 | $this->console->writeLn($spec->getException()); 111 | } 112 | 113 | if ($this->startTime) { 114 | $endTime = microtime(true); 115 | $runningTime = round($endTime - $this->startTime, 5); 116 | $this->console->writeLn("\nFinished in $runningTime seconds"); 117 | } 118 | 119 | $failedCount = count($this->failures); 120 | $incompleteCount = count($this->incompleteSpecs); 121 | $pendingCount = count($this->pendingSpecs); 122 | $specs = ($this->specCount == 1) ? 'spec' : 'specs'; 123 | $failures = ($failedCount == 1) ? 'failure' : 'failures'; 124 | $incomplete = ($incompleteCount) ? ", $incompleteCount incomplete" : ''; 125 | $pending = ($pendingCount) ? ", $pendingCount pending" : ''; 126 | 127 | // Print ASCII art if enabled 128 | if ($this->console->options['ascii']) { 129 | $this->console->writeLn(''); 130 | $this->drawAscii(); 131 | } 132 | 133 | $summaryText = "\n{$this->specCount} $specs, $failedCount $failures" . 134 | $incomplete . $pending; 135 | 136 | // Generate the summary based on whether or not it passed 137 | if ($failedCount) { 138 | $summary = $this->formatter->red($summaryText); 139 | } else { 140 | $summary = $this->formatter->green($summaryText); 141 | } 142 | 143 | $summary = $this->formatter->bold($summary); 144 | $this->console->writeLn($summary); 145 | } 146 | 147 | /** 148 | * Prints ASCII art based on whether or not any specs failed. If all specs 149 | * passed, the randomly selected art is of a happier variety, otherwise 150 | * there's a lot of anger and flipped tables. 151 | */ 152 | private function drawAscii() 153 | { 154 | $fail = [ 155 | '(╯°□°)╯︵ ┻━┻', 156 | '¯\_(ツ)_/¯', 157 | '┻━┻︵ \(°□°)/ ︵ ┻━┻', 158 | '(ಠ_ಠ)', 159 | '(ノಠ益ಠ)ノ彡', 160 | '(✖﹏✖)' 161 | ]; 162 | 163 | $pass = [ 164 | '☜(゚ヮ゚☜)', 165 | '♪ヽ( ⌒o⌒)人(⌒-⌒ )v ♪', 166 | '┗(^-^)┓', 167 | 'ヽ(^。^)ノ', 168 | 'ヽ(^▽^)v' 169 | ]; 170 | 171 | if (count($this->failures)) { 172 | $key = array_rand($fail, 1); 173 | $this->console->writeLn($fail[$key]); 174 | } else { 175 | $key = array_rand($pass, 1); 176 | $this->console->writeLn($pass[$key]); 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /spec/Runnable/SpecSpec.php: -------------------------------------------------------------------------------- 1 | suite = new Suite('TestSuite', function() {}); 9 | }); 10 | 11 | it('has its closure bound to the suite', function() { 12 | $suite = $this->suite; 13 | $suite->key = 'testvalue'; 14 | 15 | $run = function() { 16 | $closure = function() { 17 | echo $this->key; 18 | }; 19 | $spec = new Spec('spec', $closure, $this->suite); 20 | $spec->run(); 21 | }; 22 | 23 | expect($run)->toPrint('testvalue'); 24 | }); 25 | 26 | context('getResult', function() { 27 | it('returns PASSED if no exception was thrown', function() { 28 | $closure = function() {}; 29 | $spec = new Spec('spec', $closure, $this->suite); 30 | $spec->run(); 31 | 32 | expect($spec->getResult())->toBe(Spec::PASSED); 33 | }); 34 | 35 | it('returns FAILED if an exception was thrown', function() { 36 | $closure = function() { 37 | throw new \Exception('exception'); 38 | }; 39 | $spec = new Spec('spec', $closure, $this->suite); 40 | $spec->run(); 41 | 42 | expect($spec->getResult())->toBe(Spec::FAILED); 43 | }); 44 | 45 | it('returns INCOMPLETE if no closure was ran', function() { 46 | $spec = new Spec('spec', null, $this->suite); 47 | $spec->run(); 48 | 49 | expect($spec->getResult())->toBe(Spec::INCOMPLETE); 50 | }); 51 | 52 | it('returns PENDING if marked as pending', function() { 53 | $spec = new Spec('spec', null, $this->suite); 54 | $spec->setPending(); 55 | $spec->run(); 56 | 57 | expect($spec->getResult())->toBe(Spec::PENDING); 58 | }); 59 | }); 60 | 61 | context('__toString', function() { 62 | it('returns the suite title followed by the spec title', function() { 63 | $closure = function() {}; 64 | $spec = new Spec('SpecTitle', $closure, $this->suite); 65 | 66 | expect((string) $spec)->toEqual('TestSuite SpecTitle'); 67 | }); 68 | }); 69 | 70 | describe('isPassed', function() { 71 | it('returns true when the spec passed', function() { 72 | $spec = new Spec('SpecTitle', function(){}, $this->suite); 73 | $spec->run(); 74 | 75 | expect($spec->isPassed())->toBe(true); 76 | }); 77 | 78 | it('returns false when the spec did not pass', function() { 79 | $spec = new Spec('SpecTitle', function() { 80 | throw new Exception('failed'); 81 | }, $this->suite); 82 | $spec->run(); 83 | 84 | expect($spec->isPassed())->toBe(false); 85 | }); 86 | }); 87 | 88 | describe('isFailed', function() { 89 | it('returns true when the spec failed', function() { 90 | $spec = new Spec('SpecTitle', function() { 91 | throw new Exception('failed'); 92 | }, $this->suite); 93 | $spec->run(); 94 | 95 | expect($spec->isFailed())->toBe(true); 96 | }); 97 | 98 | it('returns false when the spec did not fail', function() { 99 | $spec = new Spec('SpecTitle', function(){}, $this->suite); 100 | $spec->run(); 101 | 102 | expect($spec->isFailed())->toBe(false); 103 | }); 104 | }); 105 | 106 | describe('isIncomplete', function() { 107 | it('returns true when the spec is incomplete', function() { 108 | $spec = new Spec('SpecTitle', null, $this->suite); 109 | $spec->run(); 110 | 111 | expect($spec->isIncomplete())->toBe(true); 112 | }); 113 | 114 | it('returns false when the spec is not incomplete', function() { 115 | $spec = new Spec('SpecTitle', function(){}, $this->suite); 116 | $spec->run(); 117 | 118 | expect($spec->isIncomplete())->toBe(false); 119 | }); 120 | }); 121 | 122 | describe('isPending', function() { 123 | it('returns true when the spec is pending', function() { 124 | $spec = new Spec('SpecTitle', null, $this->suite); 125 | $spec->setPending(); 126 | 127 | expect($spec->isPending())->toBe(true); 128 | }); 129 | 130 | it('returns false when the spec is not pending', function() { 131 | $spec = new Spec('SpecTitle', function(){}, $this->suite); 132 | $spec->run(); 133 | 134 | expect($spec->isPending())->toBe(false); 135 | }); 136 | }); 137 | 138 | describe('setException', function() { 139 | it('accepts a null value', function() { 140 | $spec = new Spec('spec', null, $this->suite); 141 | $spec->setException(null); 142 | expect($spec->getException())->toBe(null); 143 | }); 144 | 145 | it('updates the stored exception', function() { 146 | $error = new \Exception('foo'); 147 | $spec = new Spec('spec', null, $this->suite); 148 | $spec->setException($error); 149 | expect($spec->getException())->toBe($error); 150 | }); 151 | 152 | it('updates the spec result to FAILED', function() { 153 | $spec = new Spec('spec', null, $this->suite); 154 | $spec->setException(new \Exception('foo')); 155 | expect($spec->getResult())->toBe(Spec::FAILED); 156 | }); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /src/Console/ConsoleFormatter.php: -------------------------------------------------------------------------------- 1 | ["\033[30m", "\033[0m"], 24 | 'grey' => ["\033[90m", "\033[0m"], 25 | 'red' => ["\033[31m", "\033[0m"], 26 | 'green' => ["\033[32m", "\033[0m"], 27 | 'cyan' => ["\033[36m", "\033[0m"], 28 | 'yellow' => ["\033[33m", "\033[0m"], 29 | 'white' => ["\033[37m", "\033[0m"], 30 | ]; 31 | 32 | private static $styles = [ 33 | 'bold' => ["\x1b[1m", "\x1b[22m"], 34 | 'italic' => ["\x1b[3m", "\x1b[23m"], 35 | ]; 36 | 37 | private $enabled = true; 38 | 39 | /** 40 | * Disables string formatting using ANSI escape sequences. After being 41 | * invoked, any calls to a color or style function will result in the plain 42 | * string being returned. 43 | */ 44 | public function disableANSI() { 45 | $this->enabled = false; 46 | } 47 | 48 | /** 49 | * Given a multidimensional array, formats the text such that each entry 50 | * is left aligned with all other entries in the given column. The method 51 | * also takes an optional delimiter for specifying a sequence of characters 52 | * to separate each column. 53 | * 54 | * @param array $array The multidimensional array to format 55 | * @param string $delimiter The delimiter to be used between columns 56 | * @return array An array of strings containing the formatted entries 57 | */ 58 | public function alignText($array, $delimiter = '') 59 | { 60 | // Get max column widths 61 | $widths = []; 62 | foreach ($array as $row) { 63 | $lengths = array_map('strlen', $row); 64 | 65 | for ($i = 0; $i < count($lengths); $i++) { 66 | if (isset($widths[$i])) { 67 | $widths[$i] = max($widths[$i], $lengths[$i]); 68 | } else { 69 | $widths[$i] = $lengths[$i]; 70 | } 71 | } 72 | } 73 | 74 | // Pad lines columns and return an array 75 | $output = []; 76 | foreach($array as $row) { 77 | $entries = []; 78 | for ($i = 0; $i < count($row); $i++) { 79 | $entries[] = str_pad($row[$i], $widths[$i]); 80 | } 81 | 82 | $output[] = implode($entries, $delimiter); 83 | } 84 | 85 | return $output; 86 | } 87 | 88 | /** 89 | * Sets the text color to one of those defined in $foregroundColors. 90 | * 91 | * @param string $color A color corresponding to one of the keys in the 92 | * $foregroundColors array 93 | * @param string $text The text to be modified 94 | * @return string The original text surrounded by ANSI escape codes 95 | */ 96 | private function applyForeground($color, $text) 97 | { 98 | list($startCode, $endCode) = self::$foregroundColors[$color]; 99 | 100 | return $startCode . $text . $endCode; 101 | } 102 | 103 | /** 104 | * Sets the text style to one of those defined in $styles. 105 | * 106 | * @param string $style A style corresponding to one of the keys in the 107 | * $styles array 108 | * @param string $text The text to be modified 109 | * @return string The original text surrounded by ANSI escape codes 110 | */ 111 | private function applyStyle($style, $text) 112 | { 113 | list($startCode, $endCode) = self::$styles[$style]; 114 | 115 | return $startCode . $text . $endCode; 116 | } 117 | 118 | /** 119 | * Applies the passed text color or style to the string. If disabled, 120 | * it simply returns the passed string. 121 | * 122 | * @param string $method A color corresponding to one of the keys in the 123 | * $foregroundColors array 124 | * @param array $args An array with a single element: the text to modify 125 | * @return string The original text surrounded by ANSI escape codes 126 | * 127 | * @throws \Exception If $method doesn't correspond to any of the text 128 | * colors or styles defined in this class 129 | */ 130 | public function __call($method, $args) 131 | { 132 | if (!$this->enabled) { 133 | return $args[0]; 134 | } elseif (array_key_exists($method, self::$foregroundColors)) { 135 | return $this->applyForeground($method, $args[0]); 136 | } elseif (array_key_exists($method, self::$styles)) { 137 | return $this->applyStyle($method, $args[0]); 138 | } 139 | 140 | throw new \Exception("Method {$method} unavailable"); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/Console/ConsoleOptionParser.php: -------------------------------------------------------------------------------- 1 | options = []; 22 | $this->paths = []; 23 | $this->invalidArguments = []; 24 | } 25 | 26 | /** 27 | * Returns the ConsoleOption object either found at the key with the supplied 28 | * name, or whose short name or long name matches. 29 | * 30 | * @param string $name The name, shortName, or longName of the option 31 | * @return ConsoleOption The option matching the supplied name, if it exists 32 | */ 33 | public function getConsoleOption($name) 34 | { 35 | if (isset($this->options[$name])) { 36 | return $this->options[$name]; 37 | } 38 | 39 | foreach ($this->options as $option) { 40 | $longName = $option->getLongName(); 41 | $shortName = $option->getShortName(); 42 | 43 | if ($name == $longName || $name == $shortName) { 44 | return $option; 45 | } 46 | } 47 | } 48 | 49 | /** 50 | * Returns an associative array consisting of the names of the ConsoleOptions 51 | * added to the parser, and their values. 52 | * 53 | * @return array The names of the options and their values 54 | */ 55 | public function getOptions() 56 | { 57 | $options = []; 58 | foreach ($this->options as $name => $option) { 59 | $options[$name] = $option->getValue(); 60 | } 61 | 62 | return $options; 63 | } 64 | 65 | /** 66 | * Adds a new option to be parsed by the ConsoleOptionParser by creating 67 | * a new ConsoleOption with the supplied longName, shortName, description 68 | * and optional argumentName. The object is stored in an associative array, 69 | * using $name as the key. 70 | * 71 | * @param string $name The name of the option, to be used as a key 72 | * @param string $longName Long name of the option, including the two 73 | * preceding dashes. 74 | * @param string $shortName Short name of the option: a single 75 | * alphabetical character preceded by a dash. 76 | * @param string $description Brief description of the option. 77 | * @param string $argumentName Human readable name for the argument 78 | */ 79 | public function addOption($name, $longName, $shortName, $description, 80 | $argumentName = null) 81 | { 82 | $this->options[$name] = new ConsoleOption($longName, $shortName, 83 | $description, $argumentName); 84 | } 85 | 86 | /** 87 | * Returns an array containing the long and short names of all options. 88 | * 89 | * @return array The short and long names of the options 90 | */ 91 | public function getOptionNames() 92 | { 93 | $names = []; 94 | foreach ($this->options as $option) { 95 | $names[] = $option->getLongName(); 96 | $names[] = $option->getShortName(); 97 | } 98 | 99 | return $names; 100 | } 101 | 102 | /** 103 | * Returns an array of strings containing the paths supplied via the command 104 | * line arguments 105 | * 106 | * @return array The paths listed via the arguments 107 | */ 108 | public function getPaths() 109 | { 110 | return $this->paths; 111 | } 112 | 113 | /** 114 | * Returns an array of strings containing the passed invalid arguments. 115 | * 116 | * @return array The invalid arguments 117 | */ 118 | public function getInvalidArguments() 119 | { 120 | return $this->invalidArguments; 121 | } 122 | 123 | /** 124 | * Parses the supplied arguments, assigning their values to the stored 125 | * ConsoleOptions. If an option accepts arguments, then any following 126 | * argument is assigned as its value. Otherwise, the option is merely 127 | * a flag, and its value is set to true. Any arguments containing a dash as 128 | * their first character are assumed to be an option, and if invalid, 129 | * are stored in the $invalidOptions array. 130 | * 131 | * @param array An array of strings corresponding to the console arguments 132 | */ 133 | public function parseArguments($args) 134 | { 135 | // Loop over options 136 | for ($i = 0; $i < count($args); $i++) { 137 | if (!in_array($args[$i], $this->getOptionNames())) { 138 | // The option isn't defined 139 | if (strpos($args[$i], '-') === 0) { 140 | $this->invalidArguments[] = $args[$i]; 141 | } 142 | 143 | break; 144 | } 145 | 146 | // It's a valid option and accepts arguments, add the next argument 147 | // as its value. Otherwise, just set the option to true 148 | $option = $this->getConsoleOption($args[$i]); 149 | if ($option->acceptsArguments()) { 150 | if (isset($args[$i+1])) { 151 | $option->setValue($args[$i + 1]); 152 | $i++; 153 | } 154 | } else { 155 | $option->setValue(true); 156 | } 157 | } 158 | 159 | // The rest of the arguments are assumed to be paths 160 | if (!$this->invalidArguments && $i < count($args)) { 161 | $this->paths = array_slice($args, $i); 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /spec/Console/ConsoleOptionParserSpec.php: -------------------------------------------------------------------------------- 1 | 'testName', 8 | 'long' => 'testLongName', 9 | 'short' => 'testShortName', 10 | 'desc' => 'testDescription', 11 | 'arg' => 'testArgumentName' 12 | ]; 13 | 14 | context('addOption', function() use ($details) { 15 | it('creates a new option object', function() use ($details) { 16 | $parser = new ConsoleOptionParser(); 17 | $parser->addOption($details['name'], $details['long'], 18 | $details['short'], $details['desc'], $details['arg']); 19 | $option = $parser->getConsoleOption($details['name']); 20 | 21 | expect($option->getLongName())->toEqual($details['long']); 22 | expect($option->getShortName())->toEqual($details['short']); 23 | expect($option->getDescription())->toEqual($details['desc']); 24 | expect($option->getArgumentName())->toEqual($details['arg']); 25 | }); 26 | }); 27 | 28 | context('getConsoleOption', function() use ($details) { 29 | $parser = new ConsoleOptionParser(); 30 | $parser->addOption($details['name'], $details['long'], 31 | $details['short'], $details['desc'], $details['arg']); 32 | 33 | it('can return based on name', function() use ($parser, $details) { 34 | $option = $parser->getConsoleOption($details['name']); 35 | expect($option->getLongName())->toEqual($details['long']); 36 | }); 37 | 38 | it('can return based on longName', function() use ($parser, $details) { 39 | $option = $parser->getConsoleOption($details['long']); 40 | expect($option->getLongName())->toEqual($details['long']); 41 | }); 42 | 43 | it('can return based on shortName', function() use ($parser, $details) { 44 | $option = $parser->getConsoleOption($details['short']); 45 | expect($option->getShortName())->toEqual($details['short']); 46 | }); 47 | }); 48 | 49 | context('getOptions', function() { 50 | it('returns the values of all options, as name => value', function() { 51 | $parser = new ConsoleOptionParser(); 52 | $parser->addOption('testName1', 'long', 'short', 'desc', 'arg'); 53 | $parser->addOption('testName2', 'long2', 'short2', 'desc', 'arg'); 54 | 55 | $options = $parser->getOptions(); 56 | expect($options)->toEqual([ 57 | 'testName1' => false, 58 | 'testName2' => false 59 | ]); 60 | }); 61 | }); 62 | 63 | context('parseArguments', function() { 64 | $addOptions = function($parser) { 65 | $parser->addOption('watch', '--watch', '-w', 'desc'); 66 | $parser->addOption('ascii', '--ascii', '-a', 'desc'); 67 | 68 | $parser->addOption('reporter', '--reporter', '-r', 'desc', 'arg'); 69 | $parser->addOption('filter', '--filter', '-f', 'desc', 'arg'); 70 | }; 71 | 72 | it('parses long names', function() use ($addOptions) { 73 | $parser = new ConsoleOptionParser(); 74 | $addOptions($parser); 75 | 76 | $parser->parseArguments(['--watch', '--ascii']); 77 | $options = $parser->getOptions(); 78 | 79 | expect($options)->toEqual([ 80 | 'watch' => true, 81 | 'ascii' => true, 82 | 'reporter' => false, 83 | 'filter' => false 84 | ]); 85 | }); 86 | 87 | it('parses short names', function() use ($addOptions) { 88 | $parser = new ConsoleOptionParser(); 89 | $addOptions($parser); 90 | 91 | $parser->parseArguments(['-w']); 92 | $options = $parser->getOptions(); 93 | 94 | expect($options)->toEqual([ 95 | 'watch' => true, 96 | 'ascii' => false, 97 | 'reporter' => false, 98 | 'filter' => false 99 | ]); 100 | }); 101 | 102 | it('parses option arguments', function() use ($addOptions) { 103 | $parser = new ConsoleOptionParser(); 104 | $addOptions($parser); 105 | 106 | $parser->parseArguments(['-w', '--reporter', 'dot']); 107 | $options = $parser->getOptions(); 108 | 109 | expect($options)->toEqual([ 110 | 'watch' => true, 111 | 'ascii' => false, 112 | 'reporter' => 'dot', 113 | 'filter' => false 114 | ]); 115 | }); 116 | 117 | it('ignores option arguments at final position', function() use ($addOptions) { 118 | $parser = new ConsoleOptionParser(); 119 | $addOptions($parser); 120 | 121 | $parser->parseArguments(['--reporter']); 122 | $options = $parser->getOptions(); 123 | 124 | expect($options)->toEqual([ 125 | 'watch' => false, 126 | 'ascii' => false, 127 | 'reporter' => false, 128 | 'filter' => false 129 | ]); 130 | }); 131 | 132 | it('stores invalid options', function() use ($addOptions) { 133 | $parser = new ConsoleOptionParser(); 134 | $addOptions($parser); 135 | 136 | $parser->parseArguments(['-w', '--invalidOpt']); 137 | expect($parser->getInvalidArguments())->toEqual(['--invalidOpt']); 138 | }); 139 | 140 | it('stores paths', function() use ($addOptions) { 141 | $parser = new ConsoleOptionParser(); 142 | $addOptions($parser); 143 | 144 | $parser->parseArguments(['-r', 'spec', 'path/']); 145 | expect($parser->getPaths())->toEqual(['path/']); 146 | }); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /src/Watcher/Watcher.php: -------------------------------------------------------------------------------- 1 | modifiedTimes = []; 21 | $this->listeners = []; 22 | $this->paths = []; 23 | 24 | if (function_exists('inotify_init')) { 25 | $this->inotify = inotify_init(); 26 | 27 | $read = [$this->inotify]; 28 | $write = null; 29 | $except = null; 30 | 31 | if (stream_select($read, $write, $except, 0) !== false) { 32 | stream_set_blocking($this->inotify, 0); 33 | } else { 34 | $this->inotify = null; 35 | } 36 | } 37 | } 38 | 39 | /** 40 | * Cleans up the instance of Watcher by closing its inotify resource. 41 | */ 42 | public function __destruct() 43 | { 44 | if ($this->inotify) { 45 | fclose($this->inotify); 46 | } 47 | } 48 | 49 | /** 50 | * Adds a closure to be invoked when a path being watched is modified. 51 | * All listeners are invoked in the order in which they were added. 52 | * 53 | * @param \Closure $listener The listener to invoke on change 54 | */ 55 | public function addListener(\Closure $listener) 56 | { 57 | $this->listeners[] = $listener; 58 | } 59 | 60 | /** 61 | * Adds the path to the list of files and folders to monitor for changes. 62 | * If the path is a file, its modification time is stored for comparison. 63 | * If a directory, the modification times for each sub-directory and file 64 | * are recursively stored. 65 | * 66 | * @param string $path A valid path to a file or directory 67 | */ 68 | public function watchPath($path) 69 | { 70 | if (!$this->inotify) { 71 | $this->paths[] = $path; 72 | $this->addModifiedTimes($path); 73 | 74 | return; 75 | } 76 | 77 | $mask = IN_MODIFY | IN_ATTRIB | IN_CLOSE_WRITE | IN_MOVE | IN_CREATE | 78 | IN_DELETE | IN_DELETE_SELF | IN_MOVE_SELF; 79 | 80 | $directoryIterator = new \RecursiveDirectoryIterator(realpath($path)); 81 | $subPaths = new \RecursiveIteratorIterator($directoryIterator); 82 | 83 | // Iterate over instances of \SplFileObject 84 | foreach ($subpaths as $subPath) { 85 | if ($subPath->isDir()) { 86 | $watchDescriptor = inotify_add_watch($this->inotify, 87 | $subPath->getRealPath(), $mask); 88 | $this->paths[$watchDescriptor] = $subPath->getRealPath(); 89 | } 90 | } 91 | } 92 | 93 | /** 94 | * Starts the watcher, monitoring the paths for any changes. 95 | */ 96 | public function watch() 97 | { 98 | while (true) { 99 | if ($this->inotify) { 100 | $this->watchInotify(); 101 | } else { 102 | $this->watchTimeBased(); 103 | } 104 | 105 | sleep(1); 106 | } 107 | } 108 | 109 | /** 110 | * Monitor the paths for any changes based on the modify time information. 111 | * When a change is detected, all listeners are invoked, and the list of 112 | * paths is once again traversed to acquire updated modification timestamps. 113 | */ 114 | private function watchTimeBased() 115 | { 116 | clearstatcache(); 117 | $modified = false; 118 | 119 | // Loop over stored modified timestamps and compare them to their 120 | // current value 121 | foreach ($this->modifiedTimes as $path => $modifiedTime) { 122 | if ($modifiedTime != stat($path)['mtime']) { 123 | $modified = true; 124 | break; 125 | } 126 | } 127 | 128 | if ($modified) { 129 | $this->runListeners(); 130 | 131 | // Clear modified times and re-traverse paths 132 | $this->modifiedTimes = []; 133 | foreach ($this->paths as $path) { 134 | $this->addModifiedTimes($path); 135 | } 136 | } 137 | } 138 | 139 | /** 140 | * Monitor the paths for any changes based on the modify time information. 141 | * When a change is detected, all listeners are invoked, and the list of 142 | * paths is once again traversed to acquire updated modification timestamps. 143 | */ 144 | private function watchInotify() 145 | { 146 | if (($eventList = inotify_read($this->inotify)) === false) { 147 | return; 148 | } 149 | 150 | foreach ($eventList as $event) { 151 | $mask = $event['mask']; 152 | 153 | if ($mask & IN_DELETE_SELF || $mask & IN_MOVE_SELF) { 154 | $watchDescriptor = $event['wd']; 155 | if (inotify_rm_watch($this->inotify, $watchDescriptor)) { 156 | unset($this->paths[$watchDescriptor]); 157 | } 158 | break; 159 | } 160 | } 161 | 162 | $this->runListeners(); 163 | } 164 | 165 | /** 166 | * Given the path to a file or directory, recursively adds the modified 167 | * times of any nested folders to the modifiedTimes property. 168 | * 169 | * @param string $path A valid path to a file or directory 170 | */ 171 | private function addModifiedTimes($path) 172 | { 173 | if (is_file($path)) { 174 | $stat = stat($path); 175 | $this->modifiedTimes[realpath($path)] = $stat['mtime']; 176 | 177 | return; 178 | } 179 | 180 | $directoryIterator = new \RecursiveDirectoryIterator(realpath($path)); 181 | $files = new \RecursiveIteratorIterator($directoryIterator); 182 | 183 | // Iterate over instances of \SplFileObject 184 | foreach ($files as $file) { 185 | $modifiedTime = $file->getMTime(); 186 | $this->modifiedTimes[$file->getRealPath()] = $modifiedTime; 187 | } 188 | } 189 | 190 | /** 191 | * Invokes the listeners in the order in which they were added. 192 | */ 193 | private function runListeners() 194 | { 195 | foreach ($this->listeners as $listener) { 196 | $listener(); 197 | } 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /spec/Console/ConsoleSpec.php: -------------------------------------------------------------------------------- 1 | parseArguments(); 10 | 11 | expect($console->options)->toEqual([ 12 | 'ascii' => false, 13 | 'bootstrap' => false, 14 | 'filter' => false, 15 | 'help' => false, 16 | 'namespace' => false, 17 | 'reporter' => 'dot', 18 | 'stop' => true, 19 | 'version' => false, 20 | 'watch' => false, 21 | 'no-color' => false 22 | ]); 23 | }); 24 | 25 | context('when the help flag is used', function() { 26 | before(function() { 27 | $console = new Console(['--help'], 'php://output'); 28 | 29 | ob_start(); 30 | $console->parseArguments(); 31 | $this->printContents = ob_get_contents(); 32 | ob_end_clean(); 33 | 34 | $this->console = $console; 35 | }); 36 | 37 | it('sets the error status to 0', function() { 38 | expect($this->console->getExitStatus())->toEqual(0); 39 | }); 40 | 41 | it('prints the option list and help', function() { 42 | expect($this->printContents) 43 | ->toContain('Usage: pho [options] [files]') 44 | ->toContain('Options') 45 | ->toContain('help'); 46 | }); 47 | }); 48 | 49 | context('when the version flag is used', function() { 50 | before(function() { 51 | $console = new Console(['--version'], 'php://output'); 52 | 53 | ob_start(); 54 | $console->parseArguments(); 55 | $this->printContents = ob_get_contents(); 56 | ob_end_clean(); 57 | 58 | $this->console = $console; 59 | }); 60 | 61 | it('sets the error status to 0', function() { 62 | expect($this->console->getExitStatus())->toEqual(0); 63 | }); 64 | 65 | it('prints version info', function() { 66 | expect($this->printContents) 67 | ->toMatch('/pho version \d.\d.\d/'); 68 | }); 69 | }); 70 | 71 | context('when an invalid option is passed', function() { 72 | before(function() { 73 | $console = new Console(['--invalid'], 'php://output'); 74 | 75 | ob_start(); 76 | $console->parseArguments(); 77 | $this->printContents = ob_get_contents(); 78 | ob_end_clean(); 79 | 80 | $this->console = $console; 81 | }); 82 | 83 | it('sets the error status to 1', function() { 84 | expect($this->console->getExitStatus())->toEqual(1); 85 | }); 86 | 87 | it('lists the invalid option', function() { 88 | expect($this->printContents) 89 | ->toEqual('--invalid is not a valid option' . PHP_EOL); 90 | }); 91 | }); 92 | 93 | context('when an invalid path is used', function() { 94 | before(function() { 95 | $console = new Console(['./someinvalidpath'], 'php://output'); 96 | 97 | ob_start(); 98 | $console->parseArguments(); 99 | $this->printContents = ob_get_contents(); 100 | ob_end_clean(); 101 | 102 | $this->console = $console; 103 | }); 104 | 105 | it('sets the error status to 1', function() { 106 | expect($this->console->getExitStatus())->toEqual(1); 107 | }); 108 | 109 | it('lists the invalid path', function() { 110 | expect($this->printContents)->toEqual( 111 | "The file or path \"./someinvalidpath\" doesn't exist" . PHP_EOL); 112 | }); 113 | }); 114 | }); 115 | 116 | context('getPaths', function() { 117 | it('returns the array of parsed paths', function() { 118 | $console = new Console(['./'], 'php://output'); 119 | $console->parseArguments(); 120 | 121 | expect($console->getPaths())->toEqual(['./']); 122 | }); 123 | }); 124 | 125 | context('getReporterClass', function() { 126 | it('returns DotReporter by default', function() { 127 | $console = new Console([], 'php://output'); 128 | $console->parseArguments(); 129 | 130 | $expectedClass = 'pho\Reporter\DotReporter'; 131 | expect($console->getReporterClass())->toEqual($expectedClass); 132 | }); 133 | 134 | it('returns a valid reporter specified in the args', function() { 135 | $console = new Console(['-r', 'spec'], 'php://output'); 136 | $console->parseArguments(); 137 | 138 | $expectedClass = 'pho\Reporter\SpecReporter'; 139 | expect($console->getReporterClass())->toEqual($expectedClass); 140 | }); 141 | context('when reporter not found', function() { 142 | before(function() { 143 | $this->console = new Console(['-r', 'unkown'], 'php://output'); 144 | $this->console->parseArguments(); 145 | }); 146 | it('throw pho\Exception\ReporterNotFoundException exception', function() { 147 | expect(function() { 148 | $this->console->getReporterClass(); 149 | })->toThrow('pho\Exception\ReporterNotFoundException'); 150 | }); 151 | }); 152 | }); 153 | 154 | context('write', function() { 155 | it('prints the text to the terminal', function() { 156 | $write = function() { 157 | $console = new Console([], 'php://output'); 158 | $console->write('test'); 159 | }; 160 | expect($write)->toPrint('test'); 161 | }); 162 | }); 163 | 164 | context('writeLn', function() { 165 | it('prints the text, followed by a newline, to the terminal', function() { 166 | $writeLn = function() { 167 | $console = new Console([], 'php://output'); 168 | $console->writeLn('test'); 169 | }; 170 | expect($writeLn)->toPrint('test' . PHP_EOL); 171 | }); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /src/Suite/Suite.php: -------------------------------------------------------------------------------- 1 | title = $title; 37 | $this->closure = $closure->bindTo($this); 38 | $this->specs = []; 39 | $this->suites = []; 40 | $this->store = []; 41 | $this->parent = $parent; 42 | $this->pending = false; 43 | } 44 | 45 | /** 46 | * Returns the Suite's title. 47 | * 48 | * @return string The title 49 | */ 50 | public function getTitle() 51 | { 52 | return $this->title; 53 | } 54 | 55 | /** 56 | * Returns the Suite's closure, which may contain definitions of additional 57 | * specs and suites. 58 | * 59 | * @return string The title 60 | */ 61 | public function getClosure() 62 | { 63 | return $this->closure; 64 | } 65 | 66 | /** 67 | * Returns the parent suite, if set. 68 | * 69 | * @return string The title 70 | */ 71 | public function getParent() 72 | { 73 | return $this->parent; 74 | } 75 | 76 | /** 77 | * Returns the hook found at the specified key. Usually one of before, 78 | * after, beforeEach, or afterEach. 79 | * 80 | * @param string $key The key for the hook 81 | * @return Hook The given hook 82 | */ 83 | public function getHook($key) 84 | { 85 | if (isset($this->hooks[$key])) { 86 | return $this->hooks[$key]; 87 | } 88 | 89 | return null; 90 | } 91 | 92 | /** 93 | * Sets a hook at the specified key. 94 | * 95 | * @param string $key The key for the hook 96 | * @return Hook The given hook 97 | */ 98 | public function setHook($key, Hook $hook) 99 | { 100 | $this->hooks[$key] = $hook; 101 | } 102 | 103 | /** 104 | * Mark Suite as pending. 105 | */ 106 | public function setPending() 107 | { 108 | $this->pending = true; 109 | } 110 | 111 | /** 112 | * Returns an array of suites, which consists of nested suites. 113 | * 114 | * @return array The array of suites 115 | */ 116 | public function getSuites() 117 | { 118 | return $this->suites; 119 | } 120 | 121 | /** 122 | * Adds a suite to the list of nested suites. 123 | * 124 | * @param Suite $suite The suite to add 125 | */ 126 | public function addSuite($suite) 127 | { 128 | if (true === $this->pending) { 129 | $suite->setPending(); 130 | } 131 | $this->suites[] = $suite; 132 | } 133 | 134 | /** 135 | * Returns an array of specs contained within the suite. 136 | * 137 | * @return array The array of specs 138 | */ 139 | public function getSpecs() 140 | { 141 | return $this->specs; 142 | } 143 | 144 | /** 145 | * Adds a spec to the list of specs. 146 | * 147 | * @param Suite $suite The spec to add 148 | */ 149 | public function addSpec($spec) 150 | { 151 | $this->specs[] = $spec; 152 | } 153 | 154 | /** 155 | * Returns a string containing the parent suite's title, if a child suite, 156 | * followed by its own title. 157 | * 158 | * @return string A human readable description of the suite 159 | */ 160 | public function __toString() 161 | { 162 | if ($this->parent && $this->parent->parent) { 163 | return "{$this->parent} {$this->title}"; 164 | } 165 | 166 | return $this->title; 167 | } 168 | 169 | /** 170 | * Returns the value for the given key. If not defined within the suite's 171 | * store, tries to retrieve the value from the parent suite. 172 | * 173 | * @param mixed $key The key at which the value is stored 174 | * @return mixed The stored value, or null if none exists 175 | */ 176 | public function __get($key) 177 | { 178 | if (isset($this->store[$key])) { 179 | return $this->store[$key]; 180 | } elseif ($this->parent === null) { 181 | return null; 182 | } 183 | 184 | return $this->parent->$key; 185 | } 186 | 187 | /** 188 | * Invokes a callable stored at the given key. If not found in the suite's 189 | * store, tries to retrieve the callable from the parent suite. Throws 190 | * an exception if it does not exist. 191 | * 192 | * @param string $key The key at which the callable is located 193 | * @param mixed $arguments Optional arguments to pass to the function 194 | * @throws BadFunctionCallException If no callable exists at the key 195 | */ 196 | public function __call($key, $args = []) 197 | { 198 | if (isset($this->store[$key]) && is_callable($this->store[$key])) { 199 | return call_user_func_array($this->store[$key], $args); 200 | } elseif ($this->parent === null) { 201 | throw new \BadFunctionCallException('No callable exists at the ' . 202 | 'given key'); 203 | } 204 | 205 | return call_user_func_array([$this->parent, $key], $args); 206 | } 207 | 208 | /** 209 | * Sets the value stored at the given key for this suite. Throws an 210 | * exception if the key conflicts with the name of an existing method 211 | * in the Suite class. 212 | * 213 | * @param mixed $key The key to update 214 | * @param mixed $val The value to set at the given key 215 | * @throws \Exception If a method with the key as a name exists 216 | */ 217 | public function __set($key, $val) 218 | { 219 | if (method_exists($this, $key)) { 220 | throw new \Exception('Cannot set value in suite: key conflicts' . 221 | 'with an internal method'); 222 | } 223 | 224 | $this->store[$key] = $val; 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/pho.php: -------------------------------------------------------------------------------- 1 | describe($title, $closure); 20 | } 21 | 22 | /** 23 | * Calls the runner's xdescribe() method, creating a pending test suite with 24 | * the provided closure. 25 | * 26 | * @param string $title A title associated with this suite 27 | * @param \Closure $closure The closure associated with the suite, which may 28 | * contain nested suites and specs 29 | */ 30 | function xdescribe($title, \Closure $closure) 31 | { 32 | Runner::getInstance()->xdescribe($title, $closure); 33 | } 34 | 35 | /** 36 | * An alias for describe. Creates a test suite with the given closure. 37 | * 38 | * @param string $title A title associated with this suite 39 | * @param \Closure $closure The closure associated with the suite, which may 40 | * contain nested suites and specs 41 | */ 42 | function context($title, \Closure $closure) 43 | { 44 | Runner::getInstance()->describe($title, $closure); 45 | } 46 | 47 | /** 48 | * An alias for xdescribe. Creates a pending test suite with the given closure. 49 | * 50 | * @param string $title A title associated with this suite 51 | * @param \Closure $closure The closure associated with the suite, which may 52 | * contain nested suites and specs 53 | */ 54 | function xcontext($title, \Closure $closure) 55 | { 56 | Runner::getInstance()->xdescribe($title, $closure); 57 | } 58 | 59 | /** 60 | * Calls the runner's it() method, creating a test spec with the provided 61 | * closure. 62 | * 63 | * @param string $title A title associated with this spec 64 | * @param \Closure $closure The closure associated with the spec 65 | */ 66 | function it($title, \Closure $closure = null) 67 | { 68 | Runner::getInstance()->it($title, $closure); 69 | } 70 | 71 | /** 72 | * Calls the runner's xit() method, creating a pending test spec with the 73 | * provided closure. 74 | * 75 | * @param string $title A title associated with this spec 76 | * @param \Closure $closure The closure associated with the spec 77 | */ 78 | function xit($title, \Closure $closure = null) 79 | { 80 | Runner::getInstance()->xit($title, $closure); 81 | } 82 | 83 | /** 84 | * Calls the runner's before() method, defining a closure to be ran prior to 85 | * the parent suite's closure. 86 | * 87 | * @param \Closure $closure The closure to be ran before the suite 88 | */ 89 | function before(\Closure $closure) 90 | { 91 | Runner::getInstance()->before($closure); 92 | } 93 | 94 | /** 95 | * Calls the runner's after() method, defining a closure to be ran after the 96 | * parent suite's closure. 97 | * 98 | * @param \Closure $closure The closure to be ran after the suite 99 | */ 100 | function after(\Closure $closure) 101 | { 102 | Runner::getInstance()->after($closure); 103 | } 104 | 105 | /** 106 | * Calls the runner's beforeEach() method, defining a closure to be ran prior to 107 | * each of the parent suite's nested suites and specs. 108 | * 109 | * @param \Closure $closure The closure to be ran before each spec 110 | */ 111 | function beforeEach(\Closure $closure) 112 | { 113 | Runner::getInstance()->beforeEach($closure); 114 | } 115 | 116 | /** 117 | * Calls the runner's afterEach() method, defining a closure to be ran after 118 | * each of the parent suite's nested suites and specs. 119 | * 120 | * @param \Closure $closure The closure to be ran after the suite 121 | */ 122 | function afterEach(\Closure $closure) 123 | { 124 | Runner::getInstance()->afterEach($closure); 125 | } 126 | 127 | /** 128 | * Creates and returns a new Expectation for the supplied value. 129 | * 130 | * @param mixed $actual The value to test 131 | * @return Expectation 132 | */ 133 | function expect($actual) 134 | { 135 | return new Expectation($actual); 136 | } 137 | 138 | /** 139 | * Given a list of valid paths, recurses through directories and returns a list 140 | * of files to load. 141 | * 142 | * @param array $paths An array of strings referring to valid file paths 143 | * @return array Paths to individual files 144 | */ 145 | function expandPaths($paths) 146 | { 147 | $expanded = []; 148 | 149 | foreach ($paths as $path) { 150 | if (is_file($path)) { 151 | array_push($expanded, $path); 152 | continue; 153 | } 154 | 155 | $path = realpath($path); 156 | $dirIterator = new \RecursiveDirectoryIterator($path); 157 | $iterator = new \RecursiveIteratorIterator($dirIterator); 158 | 159 | $files = new \RegexIterator($iterator, '/^.+Spec\.php$/i', 160 | \RecursiveRegexIterator::GET_MATCH); 161 | 162 | foreach ($files as $filename => $file) { 163 | array_push($expanded, $filename); 164 | } 165 | } 166 | 167 | return $expanded; 168 | } 169 | 170 | // Create a new Console and parse arguments 171 | $console = new Console(array_slice($argv, 1), 'php://stdout'); 172 | $console->parseArguments(); 173 | 174 | // Disable color output if necessary 175 | if ($console->options['no-color']) { 176 | $console->formatter->disableANSI(); 177 | } 178 | 179 | // Exit if necessary 180 | if ($console->getExitStatus() !== null) { 181 | exit($console->getExitStatus()); 182 | } 183 | 184 | // Load global namespaced functions if required 185 | if (!$console->options['namespace']) { 186 | $path = dirname(__FILE__) . '/globalPho.php'; 187 | require_once($path); 188 | } 189 | 190 | // Bootstrap file must be required directly rather than from function 191 | // invocation to preserve any loaded globals 192 | $bootstrap = $console->options['bootstrap']; 193 | if ($bootstrap) { 194 | if (!file_exists($bootstrap)) { 195 | $console->writeLn("Bootstrap file not found: $bootstrap"); 196 | exit(1); 197 | } elseif (!is_readable($bootstrap)) { 198 | $console->writeLn("Bootstrap file not readable: $bootstrap"); 199 | exit(1); 200 | } elseif (!@include_once($bootstrap)) { 201 | $console->writeLn("Unable to include bootstrap: $bootstrap"); 202 | exit(1); 203 | } 204 | } 205 | 206 | // Files must be required directly rather than from function 207 | // invocation to preserve any loaded globals 208 | $paths = expandPaths($console->getPaths()); 209 | foreach ($paths as $path) { 210 | require_once($path); 211 | } 212 | 213 | // Start the runner 214 | Runner::$console = $console; 215 | Runner::getInstance()->run(); 216 | exit($console->getExitStatus()); 217 | -------------------------------------------------------------------------------- /spec/Expectation/Matcher/InclusionMatcherSpec.php: -------------------------------------------------------------------------------- 1 | match('TestString')) { 19 | throw new \Exception('Does not return true'); 20 | } 21 | }); 22 | 23 | it('returns false if the substring is not found in the string', function() { 24 | $matcher = new InclusionMatcher(['String']); 25 | if ($matcher->match('Test')) { 26 | throw new \Exception('Does not return false'); 27 | } 28 | }); 29 | 30 | it('returns true if the value is in the array', function() { 31 | $matcher = new InclusionMatcher([2]); 32 | if (!$matcher->match([1, 2, 3])) { 33 | throw new \Exception('Does not return true'); 34 | } 35 | }); 36 | 37 | it('returns false if the value is not in the array', function() { 38 | $matcher = new InclusionMatcher([4]); 39 | if ($matcher->match([1, 2, 3])) { 40 | throw new \Exception('Does not return false'); 41 | } 42 | }); 43 | 44 | it("throws an exception if haystack isn't an array or string", function() { 45 | $exceptionThrown = false; 46 | try { 47 | $matcher = new InclusionMatcher(['test']); 48 | $matcher->match(1); 49 | } catch (\Exception $exception) { 50 | $exceptionThrown = true; 51 | } 52 | 53 | if (!$exceptionThrown) { 54 | throw new \Exception('Does not throw an exception'); 55 | } 56 | }); 57 | }); 58 | 59 | context('match all with multiple constructor arguments', function() { 60 | it('returns true if the substrings are found in the string', function() { 61 | $matcher = new InclusionMatcher(['String', 'Test']); 62 | if (!$matcher->match('TestString')) { 63 | throw new \Exception('Does not return true'); 64 | } 65 | }); 66 | 67 | it('returns false if any substring is not found in the string', function() { 68 | $matcher = new InclusionMatcher(['Test', 'String']); 69 | if ($matcher->match('Test')) { 70 | throw new \Exception('Does not return false'); 71 | } 72 | }); 73 | 74 | it('returns true if the values are in the array', function() { 75 | $matcher = new InclusionMatcher([1, 2]); 76 | if (!$matcher->match([1, 2, 3])) { 77 | throw new \Exception('Does not return true'); 78 | } 79 | }); 80 | 81 | it('returns false if any value is not in the array', function() { 82 | $matcher = new InclusionMatcher([1, 4]); 83 | if ($matcher->match([1, 2, 3])) { 84 | throw new \Exception('Does not return false'); 85 | } 86 | }); 87 | 88 | it('accepts any number of arguments', function() { 89 | $matcher = new InclusionMatcher([1, 2, 3, 4, 5, 6, 7, 8, 9]); 90 | if ($matcher->match([1, 2, 3, 4, 5, 6, 7, 8, 10])) { 91 | throw new \Exception('Does not accept any number'); 92 | } 93 | 94 | $matcher = new InclusionMatcher([1, 2, 3, 4, 5, 6, 7, 8, 9]); 95 | if (!$matcher->match([1, 2, 3, 4, 5, 6, 7, 8, 9])) { 96 | throw new \Exception('Does not accept any number'); 97 | } 98 | }); 99 | }); 100 | 101 | context('match any with multiple constructor arguments', function() { 102 | it('returns true if any substring is found in the string', function() { 103 | $matcher = new InclusionMatcher(['spec', 'Test'], false); 104 | if (!$matcher->match('TestString')) { 105 | throw new \Exception('Does not return true'); 106 | } 107 | }); 108 | 109 | it('returns false if no substring is found', function() { 110 | $matcher = new InclusionMatcher(['test', 'String'], false); 111 | if ($matcher->match('Test')) { 112 | throw new \Exception('Does not return false'); 113 | } 114 | }); 115 | 116 | it('returns true if any value is in the array', function() { 117 | $matcher = new InclusionMatcher([5, 2], false); 118 | if (!$matcher->match([1, 2, 3])) { 119 | throw new \Exception('Does not return true'); 120 | } 121 | }); 122 | 123 | it('returns false if no value is in the array', function() { 124 | $matcher = new InclusionMatcher([4, 5]); 125 | if ($matcher->match([1, 2, 3])) { 126 | throw new \Exception('Does not return false'); 127 | } 128 | }); 129 | 130 | it('accepts any number of arguments', function() { 131 | $matcher = new InclusionMatcher([1, 2, 3, 4, 5, 6, 7, 8, 9]); 132 | if ($matcher->match(['a', 'b', 'c', 'd', 'e', 'f', 8])) { 133 | throw new \Exception('Does not accept any number'); 134 | } 135 | 136 | $matcher = new InclusionMatcher([1, 2, 3, 4, 5, 6, 7, 8, 9]); 137 | if ($matcher->match(['a', 'b', 'c', 'd', 'e', 'f'])) { 138 | throw new \Exception('Does not accept any number'); 139 | } 140 | }); 141 | }); 142 | 143 | context('getFailureMessage', function() { 144 | it('lists the expected type and needle', function() { 145 | $matcher = new InclusionMatcher(['Testing']); 146 | $matcher->match('TestString'); 147 | $expected = 'Expected string to contain Testing'; 148 | 149 | if ($expected !== $matcher->getFailureMessage()) { 150 | throw new \Exception('Did not return expected failure message'); 151 | } 152 | }); 153 | 154 | it('lists the expected type and needle with negated logic', function() { 155 | $matcher = new InclusionMatcher(['Test']); 156 | $matcher->match('TestString'); 157 | $expected = 'Expected string not to contain Test'; 158 | 159 | if ($expected !== $matcher->getFailureMessage(true)) { 160 | throw new \Exception('Did not return expected failure message'); 161 | } 162 | }); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /src/Console/Console.php: -------------------------------------------------------------------------------- 1 | ['--ascii', '-a', 'Show ASCII art on completion'], 28 | 'bootstrap' => ['--bootstrap', '-b', 'Bootstrap file to load', 'bootstrap'], 29 | 'filter' => ['--filter', '-f', 'Run specs containing a pattern', 'pattern'], 30 | 'help' => ['--help', '-h', 'Output usage information'], 31 | 'namespace' => ['--namespace', '-n', 'Only use namespaced functions'], 32 | 'reporter' => ['--reporter', '-r', 'Specify the reporter to use', 'name'], 33 | 'stop' => ['--stop', '-s', 'Stop on failure'], 34 | 'version' => ['--version', '-v', 'Display version number'], 35 | 'watch' => ['--watch', '-w', 'Watch files for changes and rerun specs'], 36 | 'no-color' => ['--no-color', '-C', 'Disable terminal colors'], 37 | 38 | // TODO: Implement options below 39 | // 'processes' => ['--processes', '-p', 'Number of processes to use', 'processes'], 40 | // 'verbose' => ['--verbose', '-V', 'Enable verbose output'] 41 | ]; 42 | 43 | private $defaultDirs = ['test', 'spec']; 44 | 45 | private $stream; 46 | 47 | /** 48 | * The constructor stores the arguments to be parsed, and creates instances 49 | * of both ConsoleFormatter and ConsoleOptionParser. Also, if either a test 50 | * or spec directory exists, they are set as the default paths to traverse. 51 | * 52 | * @param array $arguments An array of argument strings 53 | * @param string $stream The I/O stream to use when writing 54 | */ 55 | public function __construct($arguments, $stream) 56 | { 57 | $this->arguments = $arguments; 58 | $this->options = []; 59 | $this->paths = []; 60 | $this->stream = fopen($stream, 'w'); 61 | 62 | $this->formatter = new ConsoleFormatter(); 63 | $this->optionParser = new ConsoleOptionParser(); 64 | 65 | // The default folders to look in are ./test and ./spec 66 | foreach ($this->defaultDirs as $dir) { 67 | if (file_exists($dir) && is_dir($dir)) { 68 | $this->paths[] = $dir; 69 | } 70 | } 71 | } 72 | 73 | /** 74 | * Returns the namespaced name of the reporter class requested via the 75 | * command line arguments, defaulting to DotReporter if not specified. 76 | * 77 | * @return string The namespaced class name of the reporter 78 | * @throws \pho\Exception\ReporterNotFoundException 79 | */ 80 | public function getReporterClass() 81 | { 82 | $reporter = $this->options['reporter']; 83 | 84 | if ($reporter === false) { 85 | return self::DEFAULT_REPORTER; 86 | } 87 | 88 | $reporterClass = ucfirst($reporter) . 'Reporter'; 89 | $reporterClass = "pho\\Reporter\\$reporterClass"; 90 | 91 | try { 92 | $reflection = new ReflectionClass($reporterClass); 93 | } catch (ReflectionException $exception) { 94 | throw new ReporterNotFoundException($exception); 95 | } 96 | 97 | return $reflection->getName(); 98 | } 99 | 100 | /** 101 | * Returns an array of strings corresponding to file and directory paths 102 | * to be traversed. 103 | * 104 | * @return array An array of paths 105 | */ 106 | public function getPaths() 107 | { 108 | return $this->paths; 109 | } 110 | 111 | /** 112 | * Returns the error status that should be used to exit after parsing, 113 | * otherwise it returns null. 114 | * 115 | * @return mixed An integer error status, or null 116 | */ 117 | public function getExitStatus() 118 | { 119 | return $this->exitStatus; 120 | } 121 | 122 | /** 123 | * Sets the error code to be returned. 124 | * 125 | * @param int $exitStatus An integer return code or exit status 126 | */ 127 | public function setExitStatus($exitStatus) 128 | { 129 | $this->exitStatus = $exitStatus; 130 | } 131 | 132 | /** 133 | * Parses the arguments originally supplied via the constructor, assigning 134 | * their values to the option keys in the $options array. If the arguments 135 | * included the help or version option, the corresponding text is printed. 136 | * Furthermore, if the arguments included a non-valid flag or option, or 137 | * if any of the listed paths were invalid, error message is printed. 138 | */ 139 | public function parseArguments() 140 | { 141 | // Add the list of options to the OptionParser 142 | foreach ($this->availableOptions as $name => $desc) { 143 | $desc[3] = (isset($desc[3])) ? $desc[3] : null; 144 | list($longName, $shortName, $description, $argumentName) = $desc; 145 | 146 | $this->optionParser->addOption($name, $longName, $shortName, 147 | $description, $argumentName); 148 | } 149 | 150 | // Parse the arguments, assign the options 151 | $this->optionParser->parseArguments($this->arguments); 152 | $this->options = $this->optionParser->getOptions(); 153 | 154 | // Verify the paths, listing any invalid paths 155 | $paths = $this->optionParser->getPaths(); 156 | if ($paths) { 157 | $this->paths = $paths; 158 | 159 | foreach ($this->paths as $path) { 160 | if (!file_exists($path)) { 161 | $this->exitStatus = 1; 162 | $this->writeLn("The file or path \"{$path}\" doesn't exist"); 163 | } 164 | } 165 | } 166 | 167 | // Render help or version text if necessary, and display errors 168 | if ($this->options['help']) { 169 | $this->exitStatus = 0; 170 | $this->printHelp(); 171 | } elseif ($this->options['version']) { 172 | $this->exitStatus = 0; 173 | $this->printVersion(); 174 | } elseif ($this->optionParser->getInvalidArguments()) { 175 | $this->exitStatus = 1; 176 | foreach ($this->optionParser->getInvalidArguments() as $invalidArg) { 177 | $this->writeLn("$invalidArg is not a valid option"); 178 | } 179 | } 180 | } 181 | 182 | /** 183 | * Outputs a single line, replacing all occurrences of the newline character 184 | * in the string with PHP_EOL for cross-platform support. 185 | * 186 | * @param string $string The string to print 187 | */ 188 | public function write($string) 189 | { 190 | fwrite($this->stream, str_replace("\n", PHP_EOL, $string)); 191 | } 192 | 193 | /** 194 | * Outputs a line, followed by a newline, while replacing all occurrences of 195 | * '\n' in the string with PHP_EOL for cross-platform support. 196 | * 197 | * @param string $string The string to print 198 | */ 199 | public function writeLn($string) 200 | { 201 | $this->write($string); 202 | fwrite($this->stream, PHP_EOL); 203 | } 204 | 205 | /** 206 | * Outputs the help text, as required when the --help/-h flag is used. It's 207 | * done by iterating over $this->availableOptions. 208 | */ 209 | private function printHelp() 210 | { 211 | $this->writeLn("Usage: pho [options] [files]\n"); 212 | $this->writeLn("Options\n"); 213 | 214 | // Loop over availableOptions, building the necessary input for 215 | // ConsoleFormatter::alignText() 216 | $options = []; 217 | foreach ($this->availableOptions as $name => $optionInfo) { 218 | $row = [$optionInfo[1], $optionInfo[0]]; 219 | $row[] = (isset($optionInfo[3])) ? "<{$optionInfo[3]}>" : ''; 220 | $row[] = $optionInfo[2]; 221 | 222 | $options[] = $row; 223 | } 224 | 225 | $pad = str_repeat(' ', 3); 226 | foreach ($this->formatter->alignText($options, $pad) as $line) { 227 | $this->writeLn($pad . $line); 228 | } 229 | } 230 | 231 | /** 232 | * Outputs the version information, as defined in the VERSION constant. 233 | */ 234 | private function printVersion() 235 | { 236 | $this->writeLn('pho version ' . self::VERSION); 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/Runner/Runner.php: -------------------------------------------------------------------------------- 1 | root = new Suite('', function() { 32 | // no-op 33 | }, null); 34 | 35 | $this->current = $this->root; 36 | } 37 | 38 | /** 39 | * Returns the singleton instance. 40 | * 41 | * @return Runner The singleton instance 42 | */ 43 | public static function getInstance() 44 | { 45 | if (static::$instance === null) { 46 | static::$instance = new static; 47 | } 48 | 49 | return static::$instance; 50 | } 51 | 52 | /** 53 | * Constructs a test Suite, assigning it the given title and anonymous 54 | * function. If it's a child of another suite, a reference to the parent 55 | * suite is stored. This is done by tracking the current and previously 56 | * defined suites. 57 | * 58 | * @param string $title A title to be associated with the suite 59 | * @param \Closure $closure The closure to invoke when the suite is ran 60 | */ 61 | public function describe($title, \Closure $closure) 62 | { 63 | $previous = $this->current; 64 | $suite = new Suite($title, $closure, $previous); 65 | 66 | if ($this->current === $this->root) { 67 | $this->suites[] = $suite; 68 | } else { 69 | $this->current->addSuite($suite); 70 | } 71 | 72 | $this->current = $suite; 73 | $suite->getClosure()->__invoke(); 74 | $this->current = $previous; 75 | } 76 | 77 | /** 78 | * Creates a suite and marks it as pending. 79 | * 80 | * @param string $title A title to be associated with the suite 81 | * @param \Closure $closure The closure to invoke when the suite is ran 82 | */ 83 | public function xdescribe($title, \Closure $closure) 84 | { 85 | $previous = $this->current; 86 | $suite = new Suite($title, $closure, $previous); 87 | $suite->setPending(); 88 | 89 | // If current is null, this is the root suite for the file 90 | if ($this->current === null) { 91 | $this->suites[] = $suite; 92 | } else { 93 | $this->current->addSuite($suite); 94 | } 95 | 96 | $this->current = $suite; 97 | $suite->getClosure()->__invoke(); 98 | $this->current = $previous; 99 | } 100 | 101 | /** 102 | * Constructs a new Spec, adding it to the list of specs in the current 103 | * suite. 104 | * 105 | * @param string $title A title to be associated with the spec 106 | * @param \Closure $closure The closure to invoke when the spec is ran 107 | */ 108 | public function it($title, \Closure $closure = null) 109 | { 110 | $spec = new Spec($title, $closure, $this->current); 111 | $this->current->addSpec($spec); 112 | } 113 | 114 | /** 115 | * Constructs a new Spec, adding it to the list of specs in the current 116 | * suite and mark it as pending. 117 | * 118 | * @param string $title A title to be associated with the spec 119 | * @param \Closure $closure The closure to invoke when the spec is ran 120 | */ 121 | public function xit($title, \Closure $closure = null) 122 | { 123 | $spec = new Spec($title, $closure, $this->current); 124 | $spec->setPending(); 125 | $this->current->addSpec($spec); 126 | } 127 | 128 | /** 129 | * Constructs a new Hook, defining a closure to be ran prior to the parent 130 | * suite's closure. 131 | * 132 | * @param \Closure $closure The closure to be ran before the suite 133 | */ 134 | public function before(\Closure $closure) 135 | { 136 | $key = 'before'; 137 | $before = new Hook($key, $closure, $this->current); 138 | $this->current->setHook($key, $before); 139 | } 140 | 141 | /** 142 | * Constructs a new Hook, defining a closure to be ran after the parent 143 | * suite's closure. 144 | * 145 | * @param \Closure $closure The closure to be ran after the suite 146 | */ 147 | public function after(\Closure $closure) 148 | { 149 | $key = 'after'; 150 | $after = new Hook($key, $closure, $this->current); 151 | $this->current->setHook($key, $after); 152 | } 153 | 154 | /** 155 | * Constructs a new Hook, defining a closure to be ran prior to each of 156 | * the parent suite's nested suites and specs. 157 | * 158 | * @param \Closure $closure The closure to be ran before each spec 159 | */ 160 | public function beforeEach(\Closure $closure) 161 | { 162 | $key = 'beforeEach'; 163 | $beforeEach = new Hook($key, $closure, $this->current); 164 | $this->current->setHook($key, $beforeEach); 165 | } 166 | 167 | /** 168 | * Constructs a new Hook, defining a closure to be ran prior to each of 169 | * the parent suite's nested suites and specs. 170 | * 171 | * @param \Closure $closure The closure to be ran after each spec 172 | */ 173 | public function afterEach(\Closure $closure) 174 | { 175 | $key = 'afterEach'; 176 | $afterEach = new Hook($key, $closure, $this->current); 177 | $this->current->setHook($key, $afterEach); 178 | } 179 | 180 | /** 181 | * Starts the test runner by first invoking the associated reporter's 182 | * beforeRun() method, then iterating over all defined suites and running 183 | * their specs, and calling the reporter's afterRun() when complete. 184 | */ 185 | public function run() 186 | { 187 | // Get and instantiate the reporter class, load files 188 | $reporterClass = self::$console->getReporterClass(); 189 | $this->reporter = new $reporterClass(self::$console); 190 | 191 | $this->reporter->beforeRun(); 192 | foreach ($this->suites as $suite) { 193 | $this->runSuite($suite); 194 | } 195 | $this->reporter->afterRun(); 196 | 197 | if (self::$console->options['watch']) { 198 | $this->watch(); 199 | } 200 | } 201 | 202 | /** 203 | * Monitors the the current working directory for modifications, and reruns 204 | * the specs in another process on change. 205 | */ 206 | public function watch() 207 | { 208 | $watcher = new Watcher(); 209 | $watcher->watchPath(getcwd()); 210 | 211 | $watcher->addListener(function() { 212 | $paths = implode(' ', self::$console->getPaths()); 213 | $descriptor = [ 214 | 0 => ['pipe', 'r'], 215 | 1 => ['pipe', 'w'] 216 | ]; 217 | 218 | // Rebuild option string, without watch 219 | $optionString = ''; 220 | foreach (self::$console->options as $key => $val) { 221 | if ($key == 'watch') { 222 | continue; 223 | } elseif ($val === true) { // test 224 | $optionString .= "--$key "; 225 | } elseif ($val) { 226 | $optionString .= "--$key $val "; 227 | } 228 | } 229 | 230 | // Run pho in another process and echo its stdout 231 | $procStr = "{$_SERVER['SCRIPT_FILENAME']} {$optionString} {$paths}"; 232 | $process = proc_open($procStr, $descriptor, $pipes); 233 | 234 | if (is_resource($process)) { 235 | while ($buffer = fread($pipes[1], 16)) { 236 | self::$console->write($buffer); 237 | } 238 | 239 | fclose($pipes[0]); 240 | fclose($pipes[1]); 241 | proc_close($process); 242 | } 243 | }); 244 | 245 | // Ever vigilant 246 | $watcher->watch(); 247 | } 248 | 249 | /** 250 | * Runs a particular suite by running the associated hooks and reporter, 251 | * methods, iterating over its child suites and recursively calling itself, 252 | * followed by running its specs. 253 | * 254 | * @param Suite $suite The suite to run 255 | */ 256 | private function runSuite(Suite $suite) 257 | { 258 | $this->runRunnable($suite->getHook('before')); 259 | $this->reporter->beforeSuite($suite); 260 | 261 | // Run the specs 262 | $this->runSpecs($suite); 263 | 264 | // Run nested suites 265 | foreach ($suite->getSuites() as $nestedSuite) { 266 | $this->runSuite($nestedSuite); 267 | } 268 | 269 | $this->reporter->afterSuite($suite); 270 | $this->runRunnable($suite->getHook('after')); 271 | } 272 | 273 | /** 274 | * Runs the specs associated with the provided test suite. It iterates over 275 | * and runs each spec, calling the reporter's beforeSpec and afterSpec 276 | * methods, as well as the suite's beforeEach and aferEach hooks. If the 277 | * filter option is used, only specs containing a pattern are ran. And if 278 | * the stop flag is used, it quits when an exception or error is thrown. 279 | * 280 | * @param Suite $suite The suite containing the specs to run 281 | */ 282 | private function runSpecs(Suite $suite) 283 | { 284 | foreach ($suite->getSpecs() as $spec) { 285 | // If using the filter option, only run matching specs 286 | $pattern = self::$console->options['filter']; 287 | if ($pattern && !preg_match($pattern, $spec)) { 288 | continue; 289 | } 290 | 291 | $this->reporter->beforeSpec($spec); 292 | 293 | $this->runBeforeEachHooks($suite, $spec); 294 | $this->runRunnable($spec); 295 | $this->runAfterEachHooks($suite, $spec); 296 | 297 | $this->reporter->afterSpec($spec); 298 | } 299 | } 300 | 301 | /** 302 | * Runs the beforeEach hooks of the given suite and its parent suites 303 | * recursively. They are ran in the order in which they were defined, 304 | * from outer suite to inner suites. 305 | * 306 | * @param Suite $suite The suite with the hooks to run 307 | * @param Spec $spec The spec to assign any hook failures 308 | */ 309 | private function runBeforeEachHooks(Suite $suite, Spec $spec) 310 | { 311 | if ($suite->getParent()) { 312 | $this->runBeforeEachHooks($suite->getParent(), $spec); 313 | } 314 | 315 | $hook = $suite->getHook('beforeEach'); 316 | $this->runRunnable($hook); 317 | if (!$spec->getException() && $hook) { 318 | $spec->setException($hook->getException()); 319 | } 320 | } 321 | 322 | /** 323 | * Runs the afterEach hooks of the given suite and its parent suites 324 | * recursively. They are ran in the order in the opposite order, from inner 325 | * suites to outer suites. 326 | * 327 | * @param Suite $suite The suite with the hooks to run 328 | * @param Spec $spec The spec to assign any hook failures 329 | */ 330 | private function runAfterEachHooks(Suite $suite, Spec $spec) 331 | { 332 | $hook = $suite->getHook('afterEach'); 333 | $this->runRunnable($hook); 334 | if (!$spec->getException() && $hook) { 335 | $spec->setException($hook->getException()); 336 | } 337 | 338 | if ($suite->getParent()) { 339 | $this->runAfterEachHooks($suite->getParent(), $spec); 340 | } 341 | } 342 | 343 | /** 344 | * A short helper method that calls an object's run() method only if it's 345 | * an instance of Runnable. 346 | */ 347 | private function runRunnable($runnable) 348 | { 349 | if (!$runnable) return; 350 | 351 | $runnable->run(); 352 | if ($runnable->getException()) { 353 | self::$console->setExitStatus(1); 354 | if (self::$console->options['stop']) { 355 | $this->reporter->afterRun(); 356 | exit(1); 357 | } 358 | } 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![pho](http://danielstjules.com/github/pho-logo.png) 2 | 3 | BDD test framework for PHP, inspired by Jasmine and RSpec. Features a familiar 4 | syntax, and a watch command to automatically re-run specs during development. 5 | It can also be extended with custom matchers and reporters. 6 | 7 | [![Build Status](https://travis-ci.org/danielstjules/pho.svg?branch=master)](https://travis-ci.org/danielstjules/pho) 8 | 9 | * [Installation](#installation) 10 | * [Usage](#usage) 11 | * [Writing Specs](#writing-specs) 12 | * [Running Specs](#running-specs) 13 | * [Expectations/Matchers](#expectationsmatchers) 14 | * [Custom Matchers](#custom-matchers) 15 | * [Reporters](#reporters) 16 | * [Mocking](#mocking) 17 | * [Namespace](#namespace) 18 | 19 | ## Installation 20 | 21 | The following instructions outline installation using Composer. If you don't 22 | have Composer, you can download it from [http://getcomposer.org/](http://getcomposer.org/) 23 | If you're new to composer, make sure to add the vendor bin to your PATH: 24 | 25 | ``` bash 26 | # Append the following to your profile file, for example in ~/.profile 27 | export PATH=$HOME/.composer/vendor/bin:$PATH 28 | ``` 29 | 30 | To install pho, run: 31 | 32 | ``` bash 33 | composer global require danielstjules/pho 34 | ``` 35 | 36 | ## Usage 37 | 38 | ``` bash 39 | Usage: pho [options] [files] 40 | 41 | Options 42 | 43 | -a --ascii Show ASCII art on completion 44 | -b --bootstrap Bootstrap file to load 45 | -f --filter Run specs containing a pattern 46 | -h --help Output usage information 47 | -n --namespace Only use namespaced functions 48 | -r --reporter Specify the reporter to use 49 | -s --stop Stop on failure 50 | -v --version Display version number 51 | -w --watch Watch files for changes and rerun specs 52 | -C --no-color Disable terminal colors 53 | ``` 54 | 55 | ## Writing Specs 56 | 57 | Pho exposes a DSL for organizing and writing your tests, which includes the 58 | following functions: `describe`, `context`, `it`, `before`, `after`, `beforeEach`, 59 | `afterEach` and `expect`. Equivalent functions for disabling specs and suites 60 | also exist via `xdescribe`, `xcontext` and `xit`. 61 | 62 | To create a suite, `describe` and `context` can be used by passing them a 63 | string and function. Both are interchangeable, though context is more often 64 | nested in a describe to group some set of behaviour. `it` is then used to create 65 | a spec, or test. 66 | 67 | A spec may contain multiple expectations or assertions, and will pass so long 68 | as all assertions pass and no exception is uncaught. For asserting values in pho, 69 | `expect` can be used. The function accepts the value to be tested, and may be 70 | chained with a handful of matchers. 71 | 72 | ``` php 73 | toBe(true); 78 | }); 79 | 80 | it('can have specs that fail', function() { 81 | expect(false)->not()->toBe(false); 82 | }); 83 | 84 | it('can have incomplete specs'); 85 | }); 86 | ``` 87 | 88 | ![intro-screenshot](http://danielstjules.com/github/pho-intro.png) 89 | 90 | Objects may be passed between suites and specs with php's `use` keyword. Here's 91 | an example: 92 | 93 | ``` php 94 | describe('Example', function() { 95 | $object = new stdClass(); 96 | $object->name = 'pho'; 97 | 98 | context('name', function() use ($object) { 99 | it('is set to pho', function() use ($object) { 100 | expect($object->name)->toBe('pho'); 101 | }); 102 | }); 103 | }); 104 | ``` 105 | 106 | Things can get a bit verbose when dealing with multiple objects that need to be 107 | passed into closures with `use`. To avoid such long lists of arguments, `$this` 108 | can be used to set and retrieve values between suites and specs. 109 | 110 | ``` php 111 | describe('SomeClass', function() { 112 | $this->key1 = 'initialValue'; 113 | $this->key2 = 'initialValue'; 114 | 115 | context('methodOne()', function() { 116 | $this->key1 = 'changedValue'; 117 | 118 | it('contains a spec', function() { 119 | expect($this->key1)->toBe('changedValue'); 120 | expect($this->key2)->toBe('initialValue'); 121 | }); 122 | }); 123 | 124 | context('methodTwo()', function() { 125 | it('contains another spec', function() { 126 | expect($this->key1)->toBe('initialValue'); 127 | expect($this->key2)->toBe('initialValue'); 128 | }); 129 | }); 130 | }); 131 | ``` 132 | 133 | Hooks are available for running functions as setups and teardowns. `before` is 134 | ran prior to any specs in a suite, and `after`, once all in the suite have been 135 | ran. `beforeEach` and `afterEach` both run their closures once per spec. Note 136 | that `beforeEach` and `afterEach` are both stackable, and will apply to specs 137 | within nested suites. Furthermore, Global hooks may be defined in your bootstrap 138 | file. For example, an afterEach hook in a bootstrap file will run after every 139 | test in your suite. 140 | 141 | ``` php 142 | describe('Suite with Hooks', function() { 143 | $this->count = 0; 144 | 145 | beforeEach(function() { 146 | $this->count = $this->count + 1; 147 | }); 148 | 149 | it('has a count equal to 1', function() { 150 | expect($this->count)->toEqual(1); 151 | // A single beforeEach ran 152 | }); 153 | 154 | context('nested suite', function() { 155 | beforeEach(function() { 156 | $this->count = $this->count + 1; 157 | }); 158 | 159 | it('has a count equal to 3', function() { 160 | expect($this->count)->toEqual(3); 161 | // Both beforeEach closures incremented the value 162 | }); 163 | }); 164 | }); 165 | ``` 166 | 167 | ## Running Specs 168 | 169 | By default, pho looks for specs in either a `test` or `spec` folder under the 170 | working directory. It will recurse through all subfolders and run any files 171 | ending with `Spec.php`, ie: userSpec.php. Furthermore, continuous testing is as 172 | easy as using the `--watch` option, which will monitor all files in the path for 173 | changes, and rerun specs on save. 174 | 175 | ![watch](http://danielstjules.com/github/pho-watch.gif) 176 | 177 | ## Expectations/Matchers 178 | 179 | #### Type Matching 180 | 181 | ``` php 182 | expect('pho')->toBeA('string'); 183 | expect(1)->notToBeA('string'); 184 | expect(1)->not()->toBeA('string'); 185 | 186 | expect(1)->toBeAn('integer'); // Alias for toBeA 187 | expect('pho')->notToBeAn('integer'); 188 | expect('pho')->not()->toBeA('integer'); 189 | ``` 190 | 191 | #### Instance Matching 192 | 193 | ``` php 194 | expect(new User())->toBeAnInstanceOf('User'); 195 | expect(new User())->not()->toBeAnInstanceOf('Post'); 196 | expect(new User())->notToBeAnInstanceOf('Post'); 197 | ``` 198 | 199 | #### Strict Equality Matching 200 | 201 | ``` php 202 | expect(true)->toBe(true); 203 | expect(true)->not()->toBe(false); 204 | expect(true)->notToBe(false); 205 | 206 | expect(['foo'])->toEqual(['foo']); // Alias for toBe 207 | expect(['foo'])->not()->toEqual(true); 208 | expect(['foo'])->notToEqual(true); 209 | ``` 210 | 211 | #### Loose Equality Matching 212 | 213 | ``` php 214 | expect(1)->toEql(true); 215 | expect(new User('Bob'))->not()->ToEql(new User('Alice')) 216 | expect(new User('Bob'))->notToEql(new User('Alice')) 217 | ``` 218 | 219 | #### Length Matching 220 | 221 | ``` php 222 | expect(['tdd', 'bdd'])->toHaveLength(2); 223 | expect('pho')->not()->toHaveLength(2); 224 | expect('pho')->notToHaveLength(2); 225 | 226 | expect([])->toBeEmpty(); 227 | expect('pho')->not()->toBeEmpty(); 228 | expect('pho')->notToBeEmpty(); 229 | ``` 230 | 231 | #### Inclusion Matching 232 | 233 | ``` php 234 | expect('Spectacular!')->toContain('Spec'); 235 | expect(['a', 'b'])->not()->toContain('c'); 236 | expect(['a', 'b'])->notToContain('c'); 237 | 238 | expect('testing')->toContain('test', 'ing'); // Accepts multiple args 239 | expect(['tdd', 'test'])->not()->toContain('bdd', 'spec'); 240 | expect(['tdd', 'test'])->notToContain('bdd', 'spec'); 241 | 242 | expect(['name' => 'pho'])->toHaveKey('name'); 243 | expect(['name' => 'pho'])->not()->toHaveKey('id'); 244 | expect(['name' => 'pho'])->notToHaveKey('id'); 245 | ``` 246 | 247 | #### Pattern Matching 248 | 249 | ``` php 250 | expect('tdd')->toMatch('/\w[D]{2}/i'); 251 | expect('pho')->not()->toMatch('/\d+/'); 252 | expect('pho')->notToMatch('/\d+/'); 253 | 254 | expect('username')->toStartWith('user'); 255 | expect('spec')->not()->toStartWith('test'); 256 | expect('spec')->notToStartWith('test'); 257 | 258 | expect('username')->toEndWith('name'); 259 | expect('spec')->not()->toEndWith('s'); 260 | expect('spec')->notToEndtWith('s'); 261 | ``` 262 | 263 | #### Numeric Matching 264 | 265 | ``` php 266 | expect(2)->toBeGreaterThan(1); 267 | expect(2)->not()->toBeGreaterThan(2); 268 | expect(1)->notToBeGreaterThan(2); 269 | 270 | expect(2)->toBeAbove(1); // Alias for toBeGreaterThan 271 | expect(2)->not()->toBeAbove(2); 272 | expect(1)->notToBeAbove(2); 273 | 274 | expect(1)->toBeLessThan(2); 275 | expect(1)->not()->toBeLessThan(1); 276 | expect(2)->notToBeLessThan(1); 277 | 278 | expect(1)->toBeBelow(2); // Alias for toBeLessThan 279 | expect(1)->not()->toBeBelow(1); 280 | expect(2)->notToBeBelow(1); 281 | 282 | expect(1)->toBeWithin(1, 10); // Inclusive 283 | expect(-2)->not()->toBeWithin(-1, 0); 284 | expect(-2)->notToBeWithin(-1, 0); 285 | ``` 286 | 287 | #### Print Matching 288 | 289 | ``` php 290 | $callable = function() { 291 | echo 'test' 292 | }; 293 | 294 | expect($callable)->toPrint('test'); 295 | expect($callable)->not()->toPrint('testing'); 296 | expect($callable)->notToPrint('testing'); 297 | ``` 298 | 299 | #### Exception Matching 300 | 301 | ``` php 302 | $callable = function() { 303 | throw new Custom\Exception('error!'); 304 | }; 305 | 306 | expect($callable)->toThrow('Custom\Exception'); 307 | expect($callable)->not()->toThrow('\ErrorException'); 308 | expect($callable)->notToThrow('\ErrorException'); 309 | ``` 310 | 311 | ## Custom Matchers 312 | 313 | Custom matchers can be added by creating a class that implements 314 | `pho\Expectation\Matcher\MatcherInterface` and registering the matcher with 315 | `pho\Expectation\Expectation::addMatcher()`. Below is an example of a basic 316 | matcher: 317 | 318 | ``` php 319 | namespace example; 320 | 321 | use pho\Expectation\Matcher\MatcherInterface; 322 | 323 | class ExampleMatcher implements MatcherInterface 324 | { 325 | protected $expectedValue; 326 | 327 | public function __construct($expectedValue) 328 | { 329 | $this->expectedValue = $expectedValue; 330 | } 331 | 332 | public function match($actualValue) 333 | { 334 | return ($actualValue === $this->expectedValue); 335 | } 336 | 337 | public function getFailureMessage($negated = false) 338 | { 339 | if (!$negated) { 340 | return "Expected value to be {$this->expectedValue}"; 341 | } else { 342 | return "Expected value not to be {$this->expectedValue}"; 343 | } 344 | } 345 | } 346 | ``` 347 | 348 | Registering it: 349 | 350 | ``` php 351 | use pho\Expectation\Expectation; 352 | 353 | // Register the matcher 354 | Expectation::addMatcher('toHaveValue', '\example\ExampleMatcher'); 355 | ``` 356 | 357 | And that's it! You would now have access to the following: 358 | 359 | ``` php 360 | expect($actual)->toHaveValue($expected); 361 | expect($actual)->not()->toHaveValue($expected); 362 | expect($actual)->notToHaveValue($expected); 363 | ``` 364 | 365 | ## Reporters 366 | 367 | #### dot (default) 368 | 369 | ``` 370 | $ pho --reporter dot exampleSpec.php 371 | 372 | .FI 373 | 374 | Failures: 375 | 376 | "A suite can have specs that fail" FAILED 377 | /Users/danielstjules/Desktop/exampleSpec.php:9 378 | Expected false not to be false 379 | 380 | Finished in 0.00125 seconds 381 | 382 | 3 specs, 1 failure, 1 incomplete 383 | ``` 384 | 385 | #### spec 386 | 387 | ``` 388 | $ pho --reporter spec exampleSpec.php 389 | 390 | A suite 391 | contains specs with expectations 392 | can have specs that fail 393 | can have incomplete specs 394 | 395 | Failures: 396 | 397 | "A suite can have specs that fail" FAILED 398 | /Users/danielstjules/Desktop/exampleSpec.php:9 399 | Expected false not to be false 400 | 401 | Finished in 0.0012 seconds 402 | 403 | 3 specs, 1 failure, 1 incomplete 404 | ``` 405 | 406 | #### list 407 | 408 | ``` 409 | $ pho --reporter list exampleSpec.php 410 | 411 | A suite contains specs with expectations 412 | A suite can have specs that fail 413 | A suite can have incomplete specs 414 | 415 | Failures: 416 | 417 | "A suite can have specs that fail" FAILED 418 | /Users/danielstjules/Desktop/exampleSpec.php:9 419 | Expected false not to be false 420 | 421 | Finished in 0.0012 seconds 422 | 423 | 3 specs, 1 failure, 1 incomplete 424 | ``` 425 | 426 | ## Mocking 427 | 428 | Pho doesn't currently provide mocks/stubs out of the box. Instead, it's suggested 429 | that a mocking framework such as [prophecy](https://github.com/phpspec/prophecy) 430 | or [mockery](https://github.com/padraic/mockery) be used. 431 | 432 | *Note*: Tests can be failed from a test hook. If you need to check mock object 433 | expectations after running a spec, you can do so from an afterEach hook. 434 | 435 | ```php 436 | describe('A suite', function() { 437 | afterEach(function() { 438 | Mockery::close(); 439 | }); 440 | 441 | it('should check mock object expectations', function() { 442 | $mock = Mockery::mock('simplemock'); 443 | $mock->shouldReceive('foo')->with(5)->once()->andReturn(10); 444 | expect($mock->foo(5))->toBe(10); 445 | }); 446 | }); 447 | ``` 448 | 449 | ## Namespace 450 | 451 | If you'd rather not have pho use the global namespace for its functions, you 452 | can set the `--namespace` flag to force it to only use the pho namespace. This 453 | will be a nicer alternative in PHP 5.6 with 454 | [https://wiki.php.net/rfc/use_function](https://wiki.php.net/rfc/use_function) 455 | 456 | ``` php 457 | pho\describe('A suite', function() { 458 | pho\it('contains specs with expectations', function() { 459 | pho\expect(true)->toBe(true); 460 | }); 461 | 462 | pho\it('can have specs that fail', function() { 463 | pho\expect(false)->not()->toBe(false); 464 | }); 465 | }); 466 | ``` 467 | --------------------------------------------------------------------------------