Python mutation testing.
Project description
mutatest: Python mutation testing
Are you confident in your tests? Try out mutatest and see if your tests will detect small
modifications (mutations) in the code. Surviving mutations represent subtle changes that might
slip past your continuous integration checks and are undetectable by your tests.
- Features:
Simple command line tool.
Integrated with
coverageto only create meaningful mutants.Built on Python’s Abstract Syntax Tree (AST) grammar to ensure mutants are valid.
Built for efficiency with multiple running modes and random sampling of mutation targets.
Does not modify your source code, only the
__pycache__.Flexible enough to run on a whole package or a single file.
Installation
mutatest requires Python 3.7. You can install with pip:
$ pip install mutatest
Alternatively, clone this repo and install locally:
$ cd mutatest
$ pip install .
mutatest is designed to work when your test files are separated from your source directory
and are prefixed with test_. See Pytest Test Layout [2] for more details.
Using mutatest
mutatest is designed to be a diagnostic command line tool for your test coverage assessment.
The mutation trial process follows these steps when mutatest is run:
Scan for your existing Python package, or use the input source location.
Create an abstract syntax tree (AST) from the source files.
Identify locations in the code that may be mutated (line and column). If you are running with
coveragethe sample is restricted only to lines that are marked as covered in the.coveragefile.Take a random sample of the identified locations.
Apply a mutation at the location by modifying a copy of the AST and writing a new cache file to the appropriate
__pycache__location with the source file statistics.Run the test suite. This will use the mutated
__pycache__file since the source statistics are the same for modification time.See if the test suite detected the mutation by a failed test.
Remove the modified
__pycache__file.Repeat steps 5-9 for the remaining selected locations to mutate.
Write an output report of the various mutation results.
A “clean trial” of your tests is run before any mutations are applied. This same “clean trial” is
run at the end of the mutation testing. This ensures that your original test suite passes before
attempting to detect surviving mutations and that the __pycache__ has been appropriately
reset when the mutation trials are finished.
Specifying source files and test commands
If you have a Python package in a directory with an associated tests/ folder
(or internal test_ prefixed files, see the examples below) that are auto-detected
with pytest, then you can run mutatest without any arguments.
$ mutatest
It will detect the package, and run pytest by default. If you want to run with special
arguments, such as to exclude a custom marker, you can pass in the --testcmds argument
with the desired string.
Here is the command to run pytest and exclude tests marked with pytest.mark.slow.
$ mutatest --testcmds "pytest -m 'not slow'"
# using shorthand arguments
$ mutatest -t "pytest -m 'not slow'"
You can use this syntax if you want to specify a single module in your package to run and test.
$ mutatest --src mypackage/run.py --testcmds "pytest tests/test_run.py"
# using shorthand arguments
$ mutatest -s mypackage/run.py -t "pytest tests/test_run.py"
There is an option to exclude files from the source set. By default, __init__.py is
excluded. Exclude files using the --exclude argument with a space delimited list of files
in a string. Only list the file name, not paths.
$ mutatest --exclude "__init__.py _devtools.py"
# using shorthand arguments
$ mutatest -e "__init__.py _devtools.py"
Coverage optimization
Any command combination that generates a .coverage file will use that as a restriction
mechanism for the sample space to only select mutation locations that are covered. For example,
running:
$ mutatest --testcmds "pytest --cov=mypackage tests/test_run.py"
# using shorthand arguments
$ mutatest -t "pytest --cov=mypackage tests/test_run.py"
would generate the .coverage file based on tests/test_run.py. Therefore, even though
the entire package is seen only the lines covered by tests/test_run.py will be mutated
during the trials. You can override this behavior with the --nocov flag on the command line.
If you have a pytest.ini file that includes the --cov command the default behavior
of mutatest will generate the coverage file. You will see this in the CLI output at the
beginning of the trials:
$ mutatest -n 4 -t "pytest --cov=mypackage"
... prior output...
... Get mutatest targets from AST.
... Full sample space size: 115
... Coverage optimized sample space size: 75
... Selecting 4 locations from 75 potentials.
... continued output...
Auto-detected package structures
The following package structures would be auto-detected if you ran mutatest from the
same directory holding examplepkg/. You can always point to a specific directory using
the --source argument. These are outlined in the Pytest Test Layout [2] documentation.
Example with internal tests
.
└── examplepkg
├── __init__.py
├── run.py
└── test_run.py
Example with external tests
.
├── examplepkg
│ ├── __init__.py
│ └── run.py
└── tests
└── test_run.py
Selecting a running mode
mutatest has different running modes to make trials faster. The running modes determine
what will happen after a mutation trial. For example, you can choose to stop further mutations at a
location as soon as a survivor is detected. The different running mode choices are:
- Run modes:
f: full mode, run all possible combinations (slowest but most thorough).
s: break on first SURVIVOR per mutated location e.g. if there is a single surviving mutation at a location move to the next location without further testing. This is the default mode.
d: break on the first DETECTION per mutated location e.g. if there is a detected mutation on at a location move to the next one.
sd: break on the first SURVIVOR or DETECTION (fastest, and least thorough).
The API for mutatest.controller.run_mutation_trials offers finer control over the run
method beyond the CLI.
A good practice when first starting is to set the mode to sd which will stop if a mutation
survives or is detected, effectively running a single mutation per candidate location. This is the
fastest running mode and can give you a sense of investigation areas quickly.
$ mutatest --mode sd
# using shorthand arguments
$ mutatest -m sd
Controlling randomization behavior and trial number
mutatest uses random sampling of all source candidate locations and of potential mutations
to substitute at a location. You can set a random seed for repeatable trials using the
--rseed argument. The --nlocations argument controls the size of the sample
of locations to mutate. If it exceeds the number of candidate locations then the full set of
candidate locations is used.
$ mutatest --nlocations 5 --rseed 314
# using shorthand arguments
$ mutatest -n 5 -r 314
Setting the output location
By default, mutatest will write a mutation_report.rst to the current working
directory. You can set this file name and path location using the --output argument.
$ mutatest --output path/to/my_custom_file.rst
# using shorthand arguments
$ mutatest -o path/to/my_custom_file.rst
The output report will include the arguments used to generate it along with the total runtimes. The SURVIVORS section of the output report is the one you should pay attention to. These are the mutations that were undetected by your test suite. The report includes file names, line numbers, column numbers, original operation, and mutation for ease of diagnostic investigation.
Putting it all together
If you want to run 5 trials, in fast sd mode, with a random seed of 345 and an output
file name of mutation_345.rst, you would do the following if your directory structure
has a Python package folder and tests that are auto-discoverable and run by pytest.
$ mutatest -n 5 -m sd -r 345 -o mutation_345.rst
With coverage optimization if your pytest.ini file does not already specify it:
$ mutatest -n 5 -m sd -r 345 -o mutation_345.rst -t "pytest --cov=mypackage"
Getting help
Run mutatest --help to see command line arguments and supported operations:
$ mutatest --help
usage: Mutatest [-h] [-e STR_LIST] [-m {f,s,d,sd}] [-n INT] [-o PATH] [-r INT]
[-s PATH] [-t STR_CMDS] [--debug]
Python mutation testing. Mutatest will manipulate local __pycache__ files.
optional arguments:
-h, --help show this help message and exit
-e STR_LIST, --exclude STR_LIST
Space delimited string list of .py file names to exclude. (default: '__init__.py')
-m {f,s,d,sd}, --mode {f,s,d,sd}
Running modes, see the choice option descriptions below. (default: s)
-n INT, --nlocations INT
Number of locations in code to randomly select for mutation from possible targets. (default: 10)
-o PATH, --output PATH
Output file location for results. (default: mutation_report.rst)
-r INT, --rseed INT Random seed to use for sample selection.
-s PATH, --src PATH Source code (file or directory) for mutation testing. (default: auto-detection attempt).
-t STR_CMDS, --testcmds STR_CMDS
Test command string to execute. (default: 'pytest')
--debug Turn on DEBUG level logging output.
--nocov Ignore coverage files for optimization.
Mutations
mutatest is early in development and supports the following mutation operations based
on the Python AST grammar [1]:
- Supported operations:
AugAssignmutations e.g.+= -= *= /=.BinOpmutations e.g.+ - / *.BinOp Bitwise Comparisonmutations e.g.x&y x|y x^y.BinOp Bitwise Shiftmutations e.g.<< >>.BoolOpmutations e.g.and or.Comparemutations e.g.== >= < <= !=.Compare Inmutations e.g.in, not in.Compare Ismutations e.g.is, is not.Indexmutations e.g.i[0]becomesi[1]andi[-1].NameConstantmutations e.g.True,False, andNone.Slicemutations e.g. changingx[:2]tox[2:]
These are the current operations that are mutated as compatible sets.
AugAssign
Augmented assignment e.g. += -= /= *=.
- Members:
AugAssign_AddAugAssign_DivAugAssign_MultAugAssign_Sub
Example:
# source code
x += y
# mutations
x -= y # AugAssign_Sub
x *= y # AugAssign_Mult
x /= y # AugAssign_Div
BinOp
Binary operations e.g. add, subtract, divide, etc.
- Members:
ast.Addast.Divast.FloorDivast.Modast.Multast.Powast.Sub
Example:
# source code
x = a + b
# mutations
x = a / b # ast.Div
x = a - b # ast.Sub
BinOp Bit Comparison
Bitwise comparison operations e.g. x & y, x | y, x ^ y.
- Members:
ast.BitAndast.BitOrast.BitXor
Example:
# source code
x = a & y
# mutations
x = a | y # ast.BitOr
x = a ^ y # ast.BitXor
BinOp Bit Shifts
Bitwise shift operations e.g. << >>.
- Members:
ast.LShiftast.RShift
Example:
# source code
x >> y
# mutation
x << y
BoolOp
Boolean operations e.g. and or.
- Members:
ast.Andast.Or
Example:
# source code
if x and y:
# mutation
if x or y:
Compare
Comparison operations e.g. == >= <= > <.
- Members:
ast.Eqast.Gtast.GtEast.Ltast.LtEast.NotEq
Example:
# source code
x >= y
# mutations
x < y # ast.Lt
x > y # ast.Gt
x != y # ast.NotEq
Compare In
Compare membership e.g. in, not in.
- Members:
ast.Inast.NotIn
Example:
# source code
x in [1, 2, 3, 4]
# mutation
x not in [1, 2, 3, 4]
Compare Is
Comapre identity e.g. is, is not.
- Members:
ast.Isast.IsNot
Example:
# source code
x is None
# mutation
x is not None
Index
Index values for iterables e.g. i[-1], i[0], i[0][1]. It is worth noting that this is a
unique mutation form in that any index value that is positive will be marked as Index_NumPos
and the same relative behavior will happen for negative index values to Index_NumNeg. During
the mutation process there are three possible outcomes: the index is set to 0, -1 or 1.
The alternate values are chosen as potential mutations e.g. if the original operation is classified
as Index_NumPos such as x[10] then valid mutations are to x[0] or
x[-1].
- Members:
Index_NumNegIndex_NumPosIndex_NumZero
Example:
# source code
x = [a[10], a[-4], a[0]]
# mutations
x = [a[-1], a[-4], a[0]] # a[10] mutated to Index_NumNeg
x = [a[10], a[0], a[0]] # a[-4] mutated to Index_NumZero
x = [a[10], a[1], a[0]] # a[-4] mutated to Index_NumPos
x = [a[10], a[-4], a[1]] # a[0] mutated to Index_NumPos
NameConstant
Named constant mutations e.g. True, False, None.
- Members:
FalseNoneTrue
Example:
# source code
x = True
# mutations
x = False
X = None
Slices
Slice mutations to swap lower/upper values, or change range e.g. x[2:] to x[:2]
or x[1:5] to x[1:4]. This is a unique mutation. If the upper or lower bound is set to
None then the bound values are swapped. This is represented by the operations of
Slice_SwapNoneUL for swap None to the “upper” value from “lower”. The “ToZero” operations
change the list by moving the upper bound by one unit towards zero from the absolute value and
then applying the original sign e.g. x[0:2] would become x[0:1], and
x[-4:-1] would become x[-4:0]. In the positive case, which is assumed to be the
more common pattern, this results in shrinking the index slice by 1.
- Members:
Slice_SwapNoneLUSlice_SwapNoneULSlice_UNegToZeroSlice_UPosToZero
Example:
# source code
w = a[:2]
x = a[4:]
y = a[1:5]
z = a[-5:-1]
# mutation
w = a[2:] # Slice_SwapNoneUL, upper/lower bound swap when upper is originally not None
x = a[4:]
y = a[1:5]
z = a[-5:-1]
# mutation
w = a[:2]
x = a[:4] # Slice_SwapNoneLU upper/lower bound swap when lower is originally not None
y = a[1:5]
z = a[-5:-1]
# mutation
w = a[:2]
x = a[4:]
y = a[1:4] # Slice_UPosToZero, upper bound moves towards zero bound by 1 when positive
z = a[-5:-1]
# mutation
w = a[:2]
x = a[4:]
y = a[1:5]
z = a[-5:0] # Slice_UNegToZero, upper bound moves by 1 from absolute value when negative
Known limitations
Since mutatest operates on the local __pycache__ it is a serial execution process.
This means it can be slow, and will take as long as running your test suite in series for the
number of operations. It’s designed as a diagnostic tool, not something you would run in your
CICD pipeline.
If you kill the mutatest process before the trials complete you may end up
with partially mutated __pycache__ files. If this happens the best fix is to remove the
__pycache__ directories and let them rebuild automatically the next time your package is
imported (for instance, by re-running your test suite).
The mutation status is based on the return code of the test suite e.g. 0 for success, 1 for failure.
mutatest can theoretically be run with any test suite that you pass with the
--testcmds argument; however, only pytest has been tested to date. The
mutatest.maker.MutantTrialResult namedtuple contains the definitions for translating
return codes into mutation trial statuses.
Changelog
mutatest is alpha software, and backwards compatibility between releases is
not guaranteed while under development.
0.7.0
- Added new slice mutations:
Slice_SwapNoneULandSlice_SwapNoneLUfor swapping the upper and lower bound values when only one is specified e.g.x[1:]tox[:1].
Slice_UPosToZeroandSlice_UNegToZerofor moving the upper bound of a slice by 1 unit e.g.x[1:5]becomesx[1:4].
0.6.1
Added explicit tests for
argparsecli options.Added mechanism to sort reporting mutations by source file, then line number, then column number.
0.6.0
Including
pytestin the installation requirements. Technically, any test runner can be used but with all base package assumptions being built aroundpytestthis feels like the right assumption to call out as an install dependency. It is the default behavior.Updated
controllerfor test file exclusion to explicitly match prefix or suffix cases for"test_"and"_test"perpytestconventions.Changed error and unknown status results to console color as yellow instead of red.
Added multiple invariant property tests, primarily to
controllerandcache.Added
hypothesisto the test components ofextras_require.Moved to
@propertydecorators for internal class properties that should only be set at initialization, may add customsettersat a later time.Fixed a zero-division bug in the
cliwhen reporting coverage percentage.
0.5.0
Addition of
optimizers, including the new classCoverageOptimizer.This optimizer restricts the full sample space only to source locations that are marked as covered in the
.coveragefile. If you have apytest.inithat includes the--cov=command it will automatically generate during the clean-trial run.
0.4.2
More behind the scenes maintenance: updated debug level logging to include source file names and line numbers for all visit operations and separated colorized output to a new function.
0.4.1
Updated the reporting functions to return colorized display results to CLI.
0.4.0
- Added new mutation support for:
AugAssignin AST e.g.+= -= *= /=.
Indexsubstitution in lists e.g. take a positive number likei[1]and mutate to zero and a negative number e.g.i[-1] i[0].Added a
descattribute totransformers.MutationOpSetthat is used in the cli help display.Updated the cli help display to show the description and valid members.
0.3.0
Added new mutation support for
NameConstantin AST.This includes substitutions for singleton assignments such as:
True,False, andNone.This is the first non-type mutation and required adding a
readonlyparameter to thetransformers.MutateASTclass. Additionally, the type-hints for theLocIndexandMutationOpSetwere updated toAnyto support the mixed types. This was more flexible than a series ofoverloadsignatures.
0.2.0
- Added new compare mutation support for:
Compare Ismutations e.g.is, is not.
Compare Inmutations e.g.in, not in.
0.1.0
Initial release!
Requires Python 3.7 due to the
importlibinternal references for manipulating cache.Run mutation tests using the
mutatestcommand line interface.Supported operations:
BinOpmutations e.g.+ - / *including bit-operations.
Comparemutations e.g.== >= < <= !=.
BoolOpmutations e.g.and or.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mutatest-0.7.0.tar.gz.
File metadata
- Download URL: mutatest-0.7.0.tar.gz
- Upload date:
- Size: 52.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/1.13.0 pkginfo/1.5.0.1 requests/2.21.0 setuptools/39.0.1 requests-toolbelt/0.9.1 tqdm/4.31.1 CPython/3.7.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ca02198de3bb170b571cf9a76515ae4c2ff3b83d2cf0b5ba766589f3137d80ea
|
|
| MD5 |
5e560927802a15afd3601fffde60e8a6
|
|
| BLAKE2b-256 |
399d6ce67858c557de0fd4b1d2354f349aa1dcca8a439a52ffa9245aca5da370
|
File details
Details for the file mutatest-0.7.0-py37-none-any.whl.
File metadata
- Download URL: mutatest-0.7.0-py37-none-any.whl
- Upload date:
- Size: 31.0 kB
- Tags: Python 3.7
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/1.13.0 pkginfo/1.5.0.1 requests/2.21.0 setuptools/39.0.1 requests-toolbelt/0.9.1 tqdm/4.31.1 CPython/3.7.1
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f7383da43063c3d1d9f68172cae2b6e09d37792d691562d964661dd83fc7e17e
|
|
| MD5 |
968de9482699c9d323d42d2e5c339c55
|
|
| BLAKE2b-256 |
427df234a37d21a69dd4015ffadaa2c65f4490181da7994f004abee84b1bd633
|