top of page

Solidity Mutation Testing

Updated: Mar 28

Mutation testing is a method to check the quality of the test suite by intentionally introducing bugs into the code and ensuring the tests catch the bug.


The kind of bugs that get introduced is straightforward. Consider the following examples:

// original function
function mint() external payable {
	require(msg.value >= PRICE, "insufficient msg value");
}

// mutated function
function mint() external public {
  require(msg.value < PRICE, "insufficient msg value");
}

In the example above, the inequality operator was flipped. If the unit tests still pass, then the unit tests are simply offering false assurance.


It is important that the bugs be syntactically valid, i.e. still result in compilable solidity code. If the code doesn’t compile, then it won’t be possible to run the unit tests.


Line coverage without testing

Let’s use the default example foundry provides after running “forge init” and comment out the assert statements

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../src/Counter.sol";

contract CounterTest is Test {
    Counter public counter;

    function setUp() public {
        counter = new Counter();
        counter.setNumber(0);
    }

    function testIncrement() public {
        counter.increment();
        //assertEq(counter.number(), 1);
    }

    function testSetNumber(uint256 x) public {
        counter.setNumber(x);
        //assertEq(counter.number(), x);
    }
}

If we run forge coverage, we get the following table:

solidity test coverage with 100% line and branch coverage

Supposedly, we have 100% line and branch coverage on Counter.sol despite having no assert statements! This means we can introduce bugs at will and the tests will still pass.


Now of course, this is a blatant example of what not to do. But it’s easy to accidentally make this mistake when optimizing for coverage. Coverage only tells you that you ran the code and it didn’t revert. You want to ensure that all the expected state changes are actually taking place (see our other post for more on solidity unit testing best practices).


Kinds of mutants

Here are some kinds of mutations that may be useful:

  • deleting function modifiers

  • inverting inequality comparisons

  • changing constant values or swapping string constants for empty strings

  • replace true with false

  • replace && with || and bitwise & with bitwise |

  • swapping arithmetic operators (e.g. + becomes -)

  • deleting lines

  • swapping lines

Automatic mutation testing

It would be rather tedious to manually mutate the code according to the rules above and then run the test suite. Thus, tools exist that do this automatically. The generate dozens of potential mutations, mutate the code, run the test suite, store the results, and generate a report afterwards. There can be three outcomes:

  • mutant survived

  • equivalent mutant

  • mutant killed

Mutant survived means that the code was changed and the test still passed. An equivalent mutant happens when the bytecode did not change after running the mutation. This can happen if a symbol is randomly replaced with the same symbol, or the mutation doesn’t alter the business logic and the compiler optimization ignores the change.


Here is an example of where an equivalent mutant could occur:

// before
x = x + 1;
y = y + 1;

// after
y = y + 1;
x = x + 1;

Under some circumstances, the compiler might produce the same bytecode after a mutation like this. This is an equivalent mutation. Equivalent mutants might signal unecessary or dead code like in the following example:

require(false);
// anything that happens here doesn't matter

Finally, the mutant killed scenario is the desirable one. It means the code was mutated and the tests failed. Therefore, the tests can actually detect when something goes wrong. If a mutation results in non-compiling code, e.g. deleting a variable declaration that is used later, then the mutant is considered killed.


100% line and branch coverage is important for mutation testing

If a line or branch is not covered, then naturally mutating this line will not cause the test to fail.


Consider the following example

function mint(address to_, string memory questId_) public onlyMinter {
	// business logic
}

There is an implied branch here with the onlyMinter modifier. If this is only tested in a situation where the minter was the one calling the funciton, then deleting onlyMinter will not cause the test to fail. If the onlyMinter modifier doesn’t block non-minters, then the unit tests won’t catch it.


By the way, as contrived as this example may seem, it is taken from a real codearena report.


Off by one errors and boundary conditions

Mutation tests can be useful for catching off-by-one errors. Consider the following mutation:

uint256 public LIMIT = 5;

// original
function mint(uint256 amount) external {
	require(amount < LIMIT, "exceeds limit");
}

// mutation
function mint(uint256 amount) external {
	require(amount <= LIMIT, "exceeds limit");
}

If our unit tests sets amount to be 3 and 8, the code will have 100% branch coverage with respect to this test. However, the mutation tests will fail because the strict inequality was replaced with an inequality and the test still passed. This is because the tests do not accurately express the intended functionality. Specifically, the tests should enforce if the upper limit is 4 or 5. Testing values for amount like 3 or 8 do not fully define the smart contract specification for this function.


Vertigo-rs

RareSkills actively maintains a mutation testing tool for solidity, vertigo-rs. This was forked from the vertigo repo which is no longer maintained. Support for the foundry framework has been added. The tool works with foundry, hardhat, and truffle.

Instructions to run the tool are in the Readme. No modifications to the solidity codebase or tests are required. Simply clone the repository, install the dependencies, then run it in the solidity project you are testing.


Other mutation testing tools

Although vertigo-rs is the only tool that automatically runs the test suit, there are other noteable tools for generating mutations (but they don’t support automatically re-running the test suite and summarizing the results).



There are other tools, but they apparently are no longer maintained.


Mutation score

Tools for languages besides solidity sometimes provide a “mutation score.” This is the percentage of mutants that were killed. If 100% of the mutants were killed, then the unit tests can be relied upon to detect unwanted or accidental changes in the codebase.


For very large codebases, having a 100% score may be impractical. Solidity smart contracts are quite small compared to traditional codebases, such as most backend and frontend applications. Aiming for a 100% mutation score for codebases that large may be infeasible. But because solidity smart contracts are relatively small, and bugs are catastrophic, surviving mutants should be scrutinized carefully.


Limitations of mutation testing

Because mutation testing tests the quality of unit tests, and unit tests are generally stateless, mutation testing cannot naturally illuminate that stateful business logic is tested properly.


Mutation testing can create hundreds of mutations, but for the sake of time, most tools only run a subset of them. This means important mutations that uncover bugs in the test suite may be missed.


Learn More

This material is part of our Solidity bootcamp. You can also learn Solidity for free with our free Solidity course.

2,332 views1 comment

Recent Posts

See All

1 Comment


Luis Lopez
Luis Lopez
Oct 18, 2023

regarindg "Line coverage without testing". the test anyways is testing that the function call did not revert

Like
bottom of page