Mutation testing
Mutation testing evaluates the quality of software tests. Mutation testing works by creating slightly different versions of code blocks and verifying that they do not pass the same tests that the original code passes. If the mutated versions pass the tests, the tests may not be precise enough to catch potential problems.
LIGO's testing suite provides mutation testing tools to ensure that contracts are tested in a precise, thorough way and that the tests catch changes in the contracts.
Mutation testing a simple function
For an example of mutation testing, take this simple function that accepts an integer and doubles it:
To test this function, you might provide some input and output values as test cases and verify that they match:
These tests check these use cases of the twice
function:
- When run on input
0
, it returns0
. - When run on input
2
, it returns4
.
The twice
function passes the tests:
The implementation does what it intends to do. However, the implemented function is not the only function that passes the tests. Suppose a programmer made a mistake and wrote the implementation of multiplying the input by itself instead of adding it to itself, as in this example:
This faulty implementation passes the above tests because its output for each test case is the same as the correct implementation. That result suggests that the tests are not good enough to distinguish a good implementation from incorrect implementations.
To help you add more test cases and ensure that the tests are complete, you can use mutation testing to identify different versions of the function (known as mutations) that pass all of the tests.
The Test.Next.Mutation.func
function takes a value to mutate (usually a function) and a test case function to apply to mutated versions of that value.
If the test case function terminates correctly, the Test.Next.Mutation.func
function stops trying mutations and returns a Some
option with the mutation that passed all of the tests.
If no mutation passes the test case function, the Test.Next.Mutation.func
function returns None
.
For example, this code tests mutations of the correct twice
function implementation with the two test cases in the simple_tests
function:
The mutation test returns information about a function that passes all of the tests, in this case the function x * x
:
You can use this information to add a test case that the mutation fails, as in this example:
The new test case verifies that when input 1
is given, output 2
is returned.
Now you can run the mutation test again and see that no mutation that the test suite tried passes every test, giving extra confidence in the tests:
Mutating a contract
Mutation testing can also help ensure that tests cover a smart contract thoroughly. For example, this contract has two entrypoints: one that adds a number to a value in storage and one that subtracts a number from a value in storage:
Doing mutation testing on a contract with multiple entrypoints can help find entrypoints that are not covered by the tests.
For example, this test deploys a contract and tests that the Add
entrypoint works.
Note that the test uses a function named tester
to deploy the contract and run the tests on it:
This test runs mutation tests on the contract by passing mutations of it to the tester
function:
The test prints a warning about the Sub
entrypoint:
The mutation testing found that the sub
function can be changed with no consequences in the test.
This warning signals that the test does not cover the Sub
entrypoint thoroughly enough.
The following updated test adds a call to the Sub
entrypoint to test it:
When this test runs, it finds that no mutation of the Sub
entrypoint passes all of the tests and therefore does not print a warning.
Returning multiple mutations
In the previous examples, the functions Test.Next.Mutation.func
and Test.Next.Mutation.contract
return an option that contains either None or Some with a single mutation that passes the tests.
To speed up the process of eliminating mutations, you can use the Test.Next.Mutation.All.func
and Test.Next.Mutation.All.contract
functions to get every mutation that passes the tests.
These functions return a list of mutations instead of an option.
This example gets every mutation that passes the tests for the twice
function:
In this case, the output is the same because only one mutation passed all of the tests.
Similarly, the Test.Next.Mutation.All.contract
function returns a list of all contract mutations that pass the tests.
For example, this test adapts the contract test in the previous section to return every passing mutation:
In this case, the output shows that multiple mutations pass the tests:
Preventing mutation
In some cases you may want to prevent mutations from changing certain parts of your code that should not change.
To prevent such mutations, apply the @no_mutation
decorator.
This example uses this decorator to prevent mutations from changing an invariant, in this case that zero equals zero.
With this decorator, the tests will not test meaningless assertions such as that zero is less than zero or that zero equals one.
It also uses this decorator to prevent mutations from changing the Sub
entrypoint, which prevents the warnings from the previous sections: