├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml.dist ├── src └── Humbug │ └── Phpunit │ ├── Filter │ ├── FilterInterface.php │ └── TestSuite │ │ ├── AbstractFilter.php │ │ ├── FastestFirstFilter.php │ │ └── IncludeOnlyFilter.php │ ├── Listener │ ├── FilterListener.php │ └── TimeCollectorListener.php │ ├── Logger │ └── JsonLogger.php │ └── Writer │ └── JsonWriter.php └── tests └── Humbug └── Test └── Phpunit ├── Filter └── TestSuite │ ├── FastestFirstFilterTest.php │ └── IncludeOnlyFilterTest.php ├── Listener ├── FilterListenerTest.php └── TimeCollectorListenerTest.php ├── Logger └── JsonLoggerTest.php └── Writer └── JsonWriterTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | composer.lock -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | sudo: false 3 | 4 | php: 5 | - '7.0' 6 | - '7.1' 7 | - nightly 8 | - hhvm 9 | 10 | matrix: 11 | fast_finish: true 12 | include: 13 | - php: '7.0' 14 | env: COMPOSER_FLAGS="--prefer-lowest" 15 | - php: '7.1' 16 | env: COMPOSER_FLAGS="--prefer-lowest" 17 | allow_failures: 18 | - php: nightly 19 | - php: hhvm 20 | 21 | cache: 22 | directories: 23 | - $HOME/.composer/cache 24 | 25 | before_install: 26 | - composer self-update 27 | 28 | before_script: 29 | - travis_retry composer install --no-interaction --prefer-dist 30 | 31 | script: 32 | - vendor/bin/phpunit --coverage-text 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Pádraic Brady 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of Pádraic Brady, Humbug, nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Humbug PHPUnit Extensions 2 | ========================= 3 | 4 | A collection of extensions intended to allow for: 5 | * Timing of test and testsuite execution 6 | * Filter/Reorder test suites using custom filters 7 | 8 | 9 | Installation 10 | ============ 11 | 12 | ```json 13 | require: { 14 | "padraic/phpunit-extensions": "~1.0@dev" 15 | } 16 | ``` 17 | Configuration 18 | ============= 19 | 20 | Time Collector Listener 21 | ----------------------- 22 | 23 | The Time Collector Listener logs timing data on tests and test suites for use 24 | by the time dependent filters. You can enable it using the following phpunit.xml 25 | snippet showing the listeners XML block. 26 | 27 | ```xml 28 | 29 | 30 | 31 | 32 | 33 | /path/to/times.json 34 | 35 | 36 | 37 | 38 | 39 | ``` -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "padraic/phpunit-extensions", 3 | "description": "Collection of Listeners to log and manipulate test run order", 4 | "type": "library", 5 | "keywords": ["phpunit", "listener", "extension", "humbug", "tests", "testing"], 6 | "license": "BSD-3-Clause", 7 | "authors": [ 8 | { 9 | "name": "Padraic Brady", 10 | "email": "padraic.brady@gmail.com", 11 | "homepage": "http://blog.astrumfutura.com" 12 | } 13 | ], 14 | "require": { 15 | "php": "^7.0", 16 | "phpunit/phpunit": "^6.0" 17 | }, 18 | "autoload": { 19 | "psr-0": { "Humbug\\": "src/" } 20 | }, 21 | "extra": { 22 | "branch-alias": { 23 | "dev-master": "2.0-dev" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 15 | 16 | 17 | 18 | tests 19 | 20 | 21 | 22 | 23 | 24 | ./src 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/Humbug/Phpunit/Filter/FilterInterface.php: -------------------------------------------------------------------------------- 1 | log = $log; 24 | } 25 | 26 | public function filter(array $array) 27 | { 28 | $times = $this->loadTimes(); 29 | @usort($array, function (PHPUnitTestSuite $a, PHPUnitTestSuite $b) use ($times) { 30 | $na = $a->getName(); 31 | $nb = $b->getName(); 32 | if (!isset($times['suites'][$na]) || !isset($times['suites'][$nb])) { 33 | throw new RuntimeException( 34 | 'FastestFirstFilter has encountered an unlogged test suite which cannot be sorted' 35 | ); 36 | } 37 | if ($times['suites'][$na] == $times['suites'][$nb]) { 38 | return 0; 39 | } 40 | if ($times['suites'][$na] < $times['suites'][$nb]) { 41 | return -1; 42 | } 43 | 44 | return 1; 45 | }); 46 | 47 | return $array; 48 | } 49 | 50 | private function loadTimes() 51 | { 52 | if (!file_exists($this->log)) { 53 | throw new Exception(sprintf( 54 | 'Log file for collected times does not exist: %s. ' 55 | .'Use the Humbug\Phpunit\Listener\TimeCollectorListener listener prior ' 56 | .'to using the FastestFirstFilter filter at least once', 57 | $this->log 58 | )); 59 | } 60 | 61 | return json_decode(file_get_contents($this->log), true); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Humbug/Phpunit/Filter/TestSuite/IncludeOnlyFilter.php: -------------------------------------------------------------------------------- 1 | exclusivelyInclude = func_get_args(); 21 | } 22 | 23 | public function filter(array $array) 24 | { 25 | // clearly will need to revise once multi-root suites are supported 26 | $return = []; 27 | foreach ($array as $suite) { 28 | if (in_array($suite->getName(), $this->exclusivelyInclude)) { 29 | $return[] = $suite; 30 | } 31 | } 32 | 33 | return $return; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Humbug/Phpunit/Listener/FilterListener.php: -------------------------------------------------------------------------------- 1 | rootSuiteNestingLevel = $rootSuiteNestingLevel; 36 | $args = func_get_args(); 37 | array_shift($args); 38 | if (empty($args)) { 39 | throw new Exception(sprintf( 40 | 'No %s objects assigned to FilterListener', 41 | FilterInterface::class 42 | )); 43 | } 44 | foreach ($args as $filter) { 45 | $this->addFilter($filter); 46 | } 47 | } 48 | 49 | public function startTestSuite(TestSuite $suite) 50 | { 51 | $this->suiteLevel++; 52 | $this->currentSuiteName = $suite->getName(); 53 | if ($this->suiteLevel == (1 + $this->rootSuiteNestingLevel)) { 54 | $this->rootSuiteName = $suite->getName(); 55 | $suites = $suite->tests(); 56 | $filtered = $this->filterSuites($suites); 57 | $suite->setTests($filtered); 58 | } 59 | } 60 | 61 | public function endTestSuite(TestSuite $suite) 62 | { 63 | $this->suiteLevel--; 64 | } 65 | 66 | public function getSuiteLevel() 67 | { 68 | return $this->suiteLevel; 69 | } 70 | 71 | protected function filterSuites(array $suites) 72 | { 73 | $filtered = $suites; 74 | foreach ($this->suiteFilters as $filter) { 75 | $filtered = $filter->filter($filtered); 76 | } 77 | 78 | return $filtered; 79 | } 80 | 81 | protected function addFilter(FilterInterface $filter) 82 | { 83 | $this->suiteFilters[] = $filter; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Humbug/Phpunit/Listener/TimeCollectorListener.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 35 | $this->rootSuiteNestingLevel = $rootSuiteNestingLevel; 36 | } 37 | 38 | public function __destruct() 39 | { 40 | $this->rootSuiteNestingLevel = null; 41 | $this->logger = null; 42 | } 43 | 44 | public function startTestSuite(TestSuite $suite) 45 | { 46 | $this->suiteLevel++; 47 | if (!isset($this->rootSuiteName)) { 48 | $this->rootSuiteName = $suite->getName(); 49 | } 50 | $this->currentSuiteName = $suite->getName(); 51 | } 52 | 53 | /** 54 | * Logs the end of the test. 55 | * 56 | * This method hints Test for its first parameter but then uses getName(), which does not exist on that interface. 57 | * getName() exists on TestCase though, which inherits from Test. So here we assume that $test is always an instance 58 | * of TestCase rather than test. 59 | * 60 | * @param Test $test 61 | * @param float $time 62 | */ 63 | public function endTest(Test $test, $time) 64 | { 65 | $this->currentSuiteTime += $time; 66 | $this->logger->logTest( 67 | $this->currentSuiteName, 68 | $test->getName(), 69 | $time 70 | ); 71 | } 72 | 73 | public function endTestSuite(TestSuite $suite) 74 | { 75 | /* 76 | * Only log Level 2 test suites, i.e. your actual test classes. Level 1 77 | * is the parent root suite(s) defined in the XML config and Level 3 are 78 | * those hosting data provider tests. 79 | */ 80 | if ($this->suiteLevel !== (2 + $this->rootSuiteNestingLevel)) { 81 | $this->suiteLevel--; 82 | 83 | return; 84 | } 85 | $this->suiteLevel--; 86 | $this->logger->logTestSuite( 87 | $suite->getName(), 88 | $this->currentSuiteTime 89 | ); 90 | $this->currentSuiteTime = 0; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Humbug/Phpunit/Logger/JsonLogger.php: -------------------------------------------------------------------------------- 1 | writer = $writer; 35 | } 36 | 37 | public function __destruct() 38 | { 39 | $this->write(); 40 | } 41 | 42 | public function logTestSuite($title, $time) 43 | { 44 | $this->suites[$title] = $time; 45 | } 46 | 47 | public function logTest($suite, $title, $time) 48 | { 49 | if (!isset($this->tests[$suite])) { 50 | $this->tests[$suite] = []; 51 | } 52 | $this->tests[$suite][] = [ 53 | 'title' => $title, 54 | 'time' => $time, 55 | ]; 56 | } 57 | 58 | public function write() 59 | { 60 | $this->writer->write([ 61 | 'suites' => $this->suites, 62 | 'tests' => $this->tests, 63 | ]); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Humbug/Phpunit/Writer/JsonWriter.php: -------------------------------------------------------------------------------- 1 | target = $target; 24 | } 25 | 26 | /** 27 | * @param mixed $data 28 | */ 29 | public function write($data) 30 | { 31 | file_put_contents($this->target, json_encode($data, JSON_PRETTY_PRINT)); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Humbug/Test/Phpunit/Filter/TestSuite/FastestFirstFilterTest.php: -------------------------------------------------------------------------------- 1 | timeFile = $tmp.'/times.json'; 25 | file_put_contents($this->timeFile, '{"suites":{"Suite1":"2","Suite2":"3","Suite3":"1"}}'); 26 | } 27 | 28 | protected function tearDown() 29 | { 30 | @unlink($this->timeFile); 31 | } 32 | 33 | public function testShouldFilterSuitesAndReturnFastestFirst() 34 | { 35 | $filter = new FastestFirstFilter($this->timeFile); 36 | 37 | $suite1 = $this->createMock(PHPUnitTestSuite::class); 38 | $suite1->method('getName')->willReturn('Suite1'); 39 | 40 | $suite2 = $this->createMock(PHPUnitTestSuite::class); 41 | $suite2->method('getName')->willReturn('Suite2'); 42 | 43 | $suite3 = $this->createMock(PHPUnitTestSuite::class); 44 | $suite3->method('getName')->willReturn('Suite3'); 45 | 46 | $return = $filter->filter([$suite1, $suite2, $suite3]); 47 | 48 | $this->assertSame([$suite3, $suite1, $suite2], $return); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/Humbug/Test/Phpunit/Filter/TestSuite/IncludeOnlyFilterTest.php: -------------------------------------------------------------------------------- 1 | createMock(TestSuite::class); 22 | $suite1->method('getName')->willReturn('Suite1'); 23 | $suite2 = $this->createMock(TestSuite::class); 24 | $suite2->method('getName')->willReturn('Suite2'); 25 | $suite3 = $this->createMock(TestSuite::class); 26 | $suite3->method('getName')->willReturn('Suite3'); 27 | 28 | $filter = new IncludeOnlyFilter('Suite1', 'Suite3'); 29 | 30 | $return = $filter->filter([$suite1, $suite2, $suite3]); 31 | 32 | $this->assertSame([$suite1, $suite3], $return); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Humbug/Test/Phpunit/Listener/FilterListenerTest.php: -------------------------------------------------------------------------------- 1 | filter = $this->createMock(FilterInterface::class); 31 | $this->suite = $this->createMock(TestSuite::class); 32 | $this->subSuite1 = $this->createMock(TestSuite::class); 33 | $this->subSuite2 = $this->createMock(TestSuite::class); 34 | 35 | $this->suite->expects($this->atLeast(1))->method('getName')->willReturn('Suite1'); 36 | $this->suite->expects($this->once())->method('tests')->willReturn([$this->subSuite1, $this->subSuite2]); 37 | 38 | $this->filter->expects($this->once())->method('filter') 39 | ->with([$this->subSuite1, $this->subSuite2]) 40 | ->willReturn([$this->subSuite2, $this->subSuite1]); 41 | 42 | /* 43 | * The setTests method name is deceptive, it essentially accepts an array of 44 | * (sub-)TestSuite objects nested into current TestSuite. 45 | */ 46 | $this->suite->expects($this->once())->method('setTests')->with([$this->subSuite2, $this->subSuite1]); 47 | } 48 | 49 | public function testShouldFilterSubSuites() 50 | { 51 | $listener = new FilterListener(0, $this->filter); 52 | $listener->startTestSuite($this->suite); 53 | 54 | /* 55 | * Asset that nesting was reset to root suite 56 | */ 57 | $this->assertSame(1, $listener->getSuiteLevel()); 58 | 59 | $listener->endTestSuite($this->suite); 60 | 61 | $this->assertSame(0, $listener->getSuiteLevel()); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/Humbug/Test/Phpunit/Listener/TimeCollectorListenerTest.php: -------------------------------------------------------------------------------- 1 | writer = $this->createMock(JsonWriter::class); 44 | $this->writer->expects($this->once())->method('write'); // on destruction 45 | 46 | $this->test1 = $this->createMock(TestCase::class); 47 | $this->test1->expects($this->once())->method('getName')->willReturn('Test1'); 48 | 49 | $this->test2 = $this->createMock(TestCase::class); 50 | $this->test2->expects($this->once())->method('getName')->willReturn('Test2'); 51 | 52 | $this->suite = $this->createMock(TestSuite::class); 53 | $this->suite->method('getName')->willReturn('Suite1'); 54 | } 55 | 56 | public function testShouldCollectNamesAndTimesForLogging() 57 | { 58 | $listener = new TimeCollectorListener(new JsonLogger($this->writer)); 59 | $listener->startTestSuite($this->suite); 60 | $listener->endTest($this->test1, 1.0); 61 | $listener->endTest($this->test2, 2.0); 62 | $listener->endTestSuite($this->suite); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /tests/Humbug/Test/Phpunit/Logger/JsonLoggerTest.php: -------------------------------------------------------------------------------- 1 | writer = $this->createMock(JsonWriter::class); 28 | } 29 | 30 | public function testShouldWriteLogsDuringDestruct() 31 | { 32 | $this->writer->expects($this->once())->method('write'); 33 | 34 | new JsonLogger($this->writer); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Humbug/Test/Phpunit/Writer/JsonWriterTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidArgumentException::class); 14 | 15 | new JsonWriter($wrongArgument); 16 | } 17 | 18 | public function jsonWriterWrongArgumentsProvider() 19 | { 20 | return [ 21 | [''], 22 | [[]], 23 | [null], 24 | [new stdClass()], 25 | ]; 26 | } 27 | } 28 | --------------------------------------------------------------------------------