Mutation testing with Infection
Published by Matthew Daly at 13th September 2018 7:10 pm
Writing automated tests is an excellent way of catching bugs during development and maintenance of your application, not to mention the other benefits. However, it's hard to gauge the quality of your tests, particularly when you first start out. Coverage will give you a good idea of what code was actually run during the test, but it won't tell you if the test itself actually tests anything worthwhile.
Infection is a mutation testing framework. The documentation defines mutation testing as follows:
Mutation testing involves modifying a program in small ways. Each mutated version is called a Mutant. To assess the quality of a given test set, these mutants are executed against the input test set to see if the seeded faults can be detected. If mutated program produces failing tests, this is called a killed mutant. If tests are green with mutated code, then we have an escaped mutant.
Infection works by running the test suite, carrying out a series of mutations on the source code in order to try to break the tests, and then collecting the results. The actual mutations carried out are not random - there is a set of mutations that get carried out every time, so results should be consistent. Ideally, all mutants should be killed by your tests - escaped mutants can indicate that either the line of mutated code is not tested, or the tests for that line are not very useful.
I decided to add mutation testing to my Laravel shopping cart package. In order to use Infection, you need to be able to generate code coverage, which means having either XDebug or phpdbg installed. Once Infection is installed (refer to the documentation for this), you can run this command in the project directory to configure it:
$ infection
Infection defaults to using PHPUnit for the tests, but it also supports PHPSpec. If you're using PHPSpec, you will need to specify the testing framework like this:
$ infection --test-framework=phpspec
Since PHPSpec doesn't support code coverage out of the box, you'll need to install a package for that - I used leanphp/phpspec-code-coverage
.
On first run, you'll be prompted to create a configuration file. Your source directory should be straightforward to set up, but at the next step, if your project uses interfaces in the source directory, you should exclude them. The rest of the defaults should be fine.
I found that the first run gave a large number of uncovered results, but the second and later ones were more consistent - not sure if it's an issue with my setup or not. Running it gave me this:
1$ infection2You are running Infection with xdebug enabled.3 ____ ____ __ _4 / _/___ / __/__ _____/ /_(_)___ ____5 / // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ \6 _/ // / / / __/ __/ /__/ /_/ / /_/ / / / /7/___/_/ /_/_/ \___/\___/\__/_/\____/_/ /_/89 0 [>---------------------------] < 1 secRunning initial test suite...1011PHPUnit version: 6.5.131213 27 [============================] 3 secs1415Generate mutants...1617Processing source code files: 5/518Creating mutated files and processes: 43/4319.: killed, M: escaped, S: uncovered, E: fatal error, T: timed out2021...................MMM...M.......M......... (43 / 43)222343 mutations were generated:24 38 mutants were killed25 0 mutants were not covered by tests26 5 covered mutants were not detected27 0 errors were encountered28 0 time outs were encountered2930Metrics:31 Mutation Score Indicator (MSI): 88%32 Mutation Code Coverage: 100%33 Covered Code MSI: 88%3435Please note that some mutants will inevitably be harmless (i.e. false positives).3637Time: 21s. Memory: 12.00MB
Our test run shows 5 escaped mutants, and the remaining 38 were killed. We can view the results by looking at the generated infection-log.txt
:
1Escaped mutants:2================3451) /home/matthew/Projects/laravel-cart/src/Services/Cart.php:132 [M] DecrementInteger67--- Original8+++ New9@@ @@10 {11 $content = Collection::make($this->all())->map(function ($item) use($rowId) {12 if ($item['row_id'] == $rowId) {13- if ($item['qty'] > 0) {14+ if ($item['qty'] > -1) {15 $item['qty'] -= 1;16 }17 }1819202) /home/matthew/Projects/laravel-cart/src/Services/Cart.php:132 [M] OneZeroInteger2122--- Original23+++ New24@@ @@25 {26 $content = Collection::make($this->all())->map(function ($item) use($rowId) {27 if ($item['row_id'] == $rowId) {28- if ($item['qty'] > 0) {29+ if ($item['qty'] > 1) {30 $item['qty'] -= 1;31 }32 }3334353) /home/matthew/Projects/laravel-cart/src/Services/Cart.php:132 [M] GreaterThan3637--- Original38+++ New39@@ @@40 {41 $content = Collection::make($this->all())->map(function ($item) use($rowId) {42 if ($item['row_id'] == $rowId) {43- if ($item['qty'] > 0) {44+ if ($item['qty'] >= 0) {45 $item['qty'] -= 1;46 }47 }4849504) /home/matthew/Projects/laravel-cart/src/Services/Cart.php:133 [M] Assignment5152--- Original53+++ New54@@ @@55 $content = Collection::make($this->all())->map(function ($item) use($rowId) {56 if ($item['row_id'] == $rowId) {57 if ($item['qty'] > 0) {58- $item['qty'] -= 1;59+ $item['qty'] = 1;60 }61 }62 return $item;6364655) /home/matthew/Projects/laravel-cart/src/Services/Cart.php:197 [M] OneZeroInteger6667--- Original68+++ New69@@ @@70 */71 private function hasStringKeys(array $items)72 {73- return count(array_filter(array_keys($items), 'is_string')) > 0;74+ return count(array_filter(array_keys($items), 'is_string')) > 1;75 }76 /**77 * Validate input7879Timed Out mutants:80==================8182Not Covered mutants:83====================
This displays the mutants that escaped, and include a diff of the changed code, so we can see that all of these involve changing the comparison operators.
The last one can be resolved easily because the comparison is superfluous - the result of count()
can be evaluated as true or false by itself, so removing the > 0
at the end in the test solves the problem quite neatly.
The other four mutations are somewhat harder. They all amend the decrement
method's conditions, showing that a single assertion doesn't really fully check the behaviour. Here's the current test for that method:
1<?php23namespace Tests\Unit\Services;45use Tests\TestCase;6use Matthewbdaly\LaravelCart\Services\Cart;7use Mockery as m;89class CartTest extends TestCase10{11 /**12 * @dataProvider arrayProvider13 */14 public function testCanDecrementQuantity($data)15 {16 $data[0]['row_id'] = 'my_row_id_1';17 $data[1]['row_id'] = 'my_row_id_2';18 $newdata = $data;19 $newdata[1]['qty'] = 1;20 $session = m::mock('Illuminate\Contracts\Session\Session');21 $session->shouldReceive('get')->with('Matthewbdaly\LaravelCart\Services\Cart')->once()->andReturn($data);22 $session->shouldReceive('put')->with('Matthewbdaly\LaravelCart\Services\Cart', $newdata)->once();23 $uniqid = m::mock('Matthewbdaly\LaravelCart\Contracts\Services\UniqueId');24 $cart = new Cart($session, $uniqid);25 $this->assertEquals(null, $cart->decrement('my_row_id_2'));26 }27}
It should be possible to decrement it if the quantity is more than zero, but not to go any lower. However, our current test does not catch anything but decrementing it from 2 to 1, which doesn't fully demonstrate this. We therefore need to add a few more assertions to cover taking it down to zero, and then trying to decrement it again. Here's how we might do that.
1<?php23namespace Tests\Unit\Services;45use Tests\TestCase;6use Matthewbdaly\LaravelCart\Services\Cart;7use Mockery as m;89class CartTest extends TestCase10{11 /**12 * @dataProvider arrayProvider13 */14 public function testCanDecrementQuantity($data)15 {16 $data[0]['row_id'] = 'my_row_id_1';17 $data[1]['row_id'] = 'my_row_id_2';18 $newdata = $data;19 $newdata[1]['qty'] = 1;20 $session = m::mock('Illuminate\Contracts\Session\Session');21 $session->shouldReceive('get')->with('Matthewbdaly\LaravelCart\Services\Cart')->once()->andReturn($data);22 $session->shouldReceive('put')->with('Matthewbdaly\LaravelCart\Services\Cart', $newdata)->once();23 $uniqid = m::mock('Matthewbdaly\LaravelCart\Contracts\Services\UniqueId');24 $cart = new Cart($session, $uniqid);25 $this->assertEquals(null, $cart->decrement('my_row_id_2'));26 $newerdata = $newdata;27 $newerdata[1]['qty'] = 0;28 $session->shouldReceive('get')->with('Matthewbdaly\LaravelCart\Services\Cart')->once()->andReturn($newdata);29 $session->shouldReceive('put')->with('Matthewbdaly\LaravelCart\Services\Cart', $newerdata)->once();30 $this->assertEquals(null, $cart->decrement('my_row_id_2'));31 $session->shouldReceive('get')->with('Matthewbdaly\LaravelCart\Services\Cart')->once()->andReturn($newerdata);32 $session->shouldReceive('put')->with('Matthewbdaly\LaravelCart\Services\Cart', $newerdata)->once();33 $this->assertEquals(null, $cart->decrement('my_row_id_2'));34 }35}
If we re-run Infection, we now get a much better result:
1$ infection2You are running Infection with xdebug enabled.3 ____ ____ __ _4 / _/___ / __/__ _____/ /_(_)___ ____5 / // __ \/ /_/ _ \/ ___/ __/ / __ \/ __ \6 _/ // / / / __/ __/ /__/ /_/ / /_/ / / / /7/___/_/ /_/_/ \___/\___/\__/_/\____/_/ /_/89Running initial test suite...1011PHPUnit version: 6.5.131213 22 [============================] 3 secs1415Generate mutants...1617Processing source code files: 5/518Creating mutated files and processes: 41/4119.: killed, M: escaped, S: uncovered, E: fatal error, T: timed out2021......................................... (41 / 41)222341 mutations were generated:24 41 mutants were killed25 0 mutants were not covered by tests26 0 covered mutants were not detected27 0 errors were encountered28 0 time outs were encountered2930Metrics:31 Mutation Score Indicator (MSI): 100%32 Mutation Code Coverage: 100%33 Covered Code MSI: 100%3435Please note that some mutants will inevitably be harmless (i.e. false positives).3637Time: 19s. Memory: 12.00MB
Code coverage only tells you what lines of code are actually executed - it doesn't tell you much about how effectively that line of code is tested. Infection gives you a different insight into the quality of your tests, helping to write better ones. I've so far found it very useful for getting feedback on the quality of my tests. It's interesting that PHPSpec tests seem to have a consistently lower proportion of escaped mutants than PHPUnit ones - perhaps the more natural workflow when writing specs with PHPSpec makes it easier to write good tests.