├── .Rbuildignore
├── .github
├── .gitignore
├── FUNDING.yml
└── workflows
│ ├── R-CMD-check.yaml
│ ├── lint.yaml
│ ├── pkgdown.yaml
│ ├── pr-commands.yaml
│ ├── rhub.yaml
│ ├── test-acceptance.yaml
│ ├── test-coverage.yaml
│ └── test-mutation.yaml
├── .gitignore
├── .lintr
├── DESCRIPTION
├── LICENSE
├── LICENSE.md
├── NAMESPACE
├── NEWS.md
├── R
├── mutator-operator.R
├── muttest.R
├── project_copy_strategy.R
├── reporter-progress.R
├── reporter.R
└── test_strategy.R
├── README.Rmd
├── README.md
├── _pkgdown.yml
├── codecov.yml
├── cran-comments.md
├── inst
└── examples
│ └── operators
│ ├── DESCRIPTION
│ ├── R
│ └── calculate.R
│ └── tests
│ └── testthat
│ ├── helper.R
│ ├── setup.R
│ └── test-calculate.R
├── man
├── CopyStrategy.Rd
├── FileTestStrategy.Rd
├── FullTestStrategy.Rd
├── MutationReporter.Rd
├── PackageCopyStrategy.Rd
├── ProgressMutationReporter.Rd
├── TestStrategy.Rd
├── default_copy_strategy.Rd
├── default_reporter.Rd
├── default_test_strategy.Rd
├── figures
│ └── logo.png
├── muttest.Rd
├── operator.Rd
└── plan.Rd
├── muttest.Rproj
├── pkgdown
└── favicon
│ ├── apple-touch-icon.png
│ ├── favicon-96x96.png
│ ├── favicon.ico
│ ├── favicon.svg
│ ├── site.webmanifest
│ ├── web-app-manifest-192x192.png
│ └── web-app-manifest-512x512.png
├── tests
├── acceptance
│ ├── setup-steps.R
│ ├── setup.R
│ ├── test_box.feature
│ ├── test_package.feature
│ └── test_repo.feature
├── testthat.R
└── testthat
│ ├── test-acceptance.R
│ ├── test-mutator-operator.R
│ ├── test-muttest.R
│ ├── test-project_copy_strategy.R
│ └── test-test_strategy.R
└── vignettes
└── articles
├── .gitignore
└── how-it-works.Rmd
/.Rbuildignore:
--------------------------------------------------------------------------------
1 | ^.*\.Rproj$
2 | ^\.Rproj\.user$
3 | ^LICENSE\.md$
4 | ^codecov\.yml$
5 | ^_pkgdown\.yml$
6 | ^docs$
7 | ^pkgdown$
8 | ^\.github$
9 | ^README\.Rmd$
10 | ^vignettes/articles$
11 | ^\.lintr$
12 | ^CRAN-SUBMISSION$
13 | ^cran-comments\.md$
14 |
--------------------------------------------------------------------------------
/.github/.gitignore:
--------------------------------------------------------------------------------
1 | *.html
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [jakubsob]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12 | polar: # Replace with a single Polar username
13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
14 | thanks_dev: # Replace with a single thanks.dev username
15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
16 |
--------------------------------------------------------------------------------
/.github/workflows/R-CMD-check.yaml:
--------------------------------------------------------------------------------
1 | # Workflow derived from https://github.com/r-lib/actions/tree/v2/examples
2 | # Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help
3 | on:
4 | push:
5 | branches: [main, master]
6 | pull_request:
7 |
8 | name: R-CMD-check.yaml
9 |
10 | permissions: read-all
11 |
12 | jobs:
13 | R-CMD-check:
14 | runs-on: ${{ matrix.config.os }}
15 |
16 | name: ${{ matrix.config.os }} (${{ matrix.config.r }})
17 |
18 | strategy:
19 | fail-fast: false
20 | matrix:
21 | config:
22 | - {os: macos-latest, r: 'release'}
23 | - {os: windows-latest, r: 'release'}
24 | - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'}
25 | - {os: ubuntu-latest, r: 'release'}
26 | - {os: ubuntu-latest, r: 'oldrel-1'}
27 |
28 | env:
29 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
30 | R_KEEP_PKG_SOURCE: yes
31 |
32 | steps:
33 | - uses: actions/checkout@v4
34 |
35 | - uses: r-lib/actions/setup-pandoc@v2
36 |
37 | - uses: r-lib/actions/setup-r@v2
38 | with:
39 | r-version: ${{ matrix.config.r }}
40 | http-user-agent: ${{ matrix.config.http-user-agent }}
41 | use-public-rspm: true
42 |
43 | - uses: r-lib/actions/setup-r-dependencies@v2
44 | with:
45 | extra-packages: any::rcmdcheck
46 | needs: check
47 |
48 | - uses: r-lib/actions/check-r-package@v2
49 | with:
50 | upload-snapshots: true
51 | build_args: 'c("--no-manual","--compact-vignettes=gs+qpdf")'
52 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yaml:
--------------------------------------------------------------------------------
1 | # Workflow derived from https://github.com/r-lib/actions/tree/v2/examples
2 | # Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help
3 | on:
4 | push:
5 | branches: [main, master]
6 | pull_request:
7 |
8 | name: lint.yaml
9 |
10 | permissions: read-all
11 |
12 | jobs:
13 | lint:
14 | runs-on: ubuntu-latest
15 | env:
16 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
17 | steps:
18 | - uses: actions/checkout@v4
19 |
20 | - uses: r-lib/actions/setup-r@v2
21 | with:
22 | use-public-rspm: true
23 |
24 | - uses: r-lib/actions/setup-r-dependencies@v2
25 | with:
26 | extra-packages: any::lintr, local::.
27 | needs: lint
28 |
29 | - name: Lint
30 | run: lintr::lint_package()
31 | shell: Rscript {0}
32 | env:
33 | LINTR_ERROR_ON_LINT: true
34 |
--------------------------------------------------------------------------------
/.github/workflows/pkgdown.yaml:
--------------------------------------------------------------------------------
1 | # Workflow derived from https://github.com/r-lib/actions/tree/v2/examples
2 | # Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help
3 | on:
4 | push:
5 | branches: [main, master]
6 | pull_request:
7 | release:
8 | types: [published]
9 | workflow_dispatch:
10 |
11 | name: pkgdown.yaml
12 |
13 | permissions: read-all
14 |
15 | jobs:
16 | pkgdown:
17 | runs-on: ubuntu-latest
18 | # Only restrict concurrency for non-PR jobs
19 | concurrency:
20 | group: pkgdown-${{ github.event_name != 'pull_request' || github.run_id }}
21 | env:
22 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
23 | permissions:
24 | contents: write
25 | steps:
26 | - uses: actions/checkout@v4
27 |
28 | - uses: r-lib/actions/setup-pandoc@v2
29 |
30 | - uses: r-lib/actions/setup-r@v2
31 | with:
32 | use-public-rspm: true
33 |
34 | - uses: r-lib/actions/setup-r-dependencies@v2
35 | with:
36 | extra-packages: any::pkgdown, local::.
37 | needs: website
38 |
39 | - name: Build site
40 | run: pkgdown::build_site_github_pages(new_process = FALSE, install = FALSE)
41 | shell: Rscript {0}
42 |
43 | - name: Deploy to GitHub pages 🚀
44 | if: github.event_name != 'pull_request'
45 | uses: JamesIves/github-pages-deploy-action@v4.5.0
46 | with:
47 | clean: false
48 | branch: gh-pages
49 | folder: docs
50 |
--------------------------------------------------------------------------------
/.github/workflows/pr-commands.yaml:
--------------------------------------------------------------------------------
1 | # Workflow derived from https://github.com/r-lib/actions/tree/v2/examples
2 | # Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help
3 | on:
4 | issue_comment:
5 | types: [created]
6 |
7 | name: pr-commands.yaml
8 |
9 | permissions: read-all
10 |
11 | jobs:
12 | document:
13 | if: ${{ github.event.issue.pull_request && (github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER') && startsWith(github.event.comment.body, '/document') }}
14 | name: document
15 | runs-on: ubuntu-latest
16 | env:
17 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
18 | permissions:
19 | contents: write
20 | steps:
21 | - uses: actions/checkout@v4
22 |
23 | - uses: r-lib/actions/pr-fetch@v2
24 | with:
25 | repo-token: ${{ secrets.GITHUB_TOKEN }}
26 |
27 | - uses: r-lib/actions/setup-r@v2
28 | with:
29 | use-public-rspm: true
30 |
31 | - uses: r-lib/actions/setup-r-dependencies@v2
32 | with:
33 | extra-packages: any::roxygen2
34 | needs: pr-document
35 |
36 | - name: Document
37 | run: roxygen2::roxygenise()
38 | shell: Rscript {0}
39 |
40 | - name: commit
41 | run: |
42 | git config --local user.name "$GITHUB_ACTOR"
43 | git config --local user.email "$GITHUB_ACTOR@users.noreply.github.com"
44 | git add man/\* NAMESPACE
45 | git commit -m 'Document'
46 |
47 | - uses: r-lib/actions/pr-push@v2
48 | with:
49 | repo-token: ${{ secrets.GITHUB_TOKEN }}
50 |
51 | style:
52 | if: ${{ github.event.issue.pull_request && (github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER') && startsWith(github.event.comment.body, '/style') }}
53 | name: style
54 | runs-on: ubuntu-latest
55 | env:
56 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
57 | permissions:
58 | contents: write
59 | steps:
60 | - uses: actions/checkout@v4
61 |
62 | - uses: r-lib/actions/pr-fetch@v2
63 | with:
64 | repo-token: ${{ secrets.GITHUB_TOKEN }}
65 |
66 | - uses: r-lib/actions/setup-r@v2
67 |
68 | - name: Install dependencies
69 | run: install.packages("styler")
70 | shell: Rscript {0}
71 |
72 | - name: Style
73 | run: styler::style_pkg()
74 | shell: Rscript {0}
75 |
76 | - name: commit
77 | run: |
78 | git config --local user.name "$GITHUB_ACTOR"
79 | git config --local user.email "$GITHUB_ACTOR@users.noreply.github.com"
80 | git add \*.R
81 | git commit -m 'Style'
82 |
83 | - uses: r-lib/actions/pr-push@v2
84 | with:
85 | repo-token: ${{ secrets.GITHUB_TOKEN }}
86 |
--------------------------------------------------------------------------------
/.github/workflows/rhub.yaml:
--------------------------------------------------------------------------------
1 | # R-hub's generic GitHub Actions workflow file. It's canonical location is at
2 | # https://github.com/r-hub/actions/blob/v1/workflows/rhub.yaml
3 | # You can update this file to a newer version using the rhub2 package:
4 | #
5 | # rhub::rhub_setup()
6 | #
7 | # It is unlikely that you need to modify this file manually.
8 |
9 | name: R-hub
10 | run-name: "${{ github.event.inputs.id }}: ${{ github.event.inputs.name || format('Manually run by {0}', github.triggering_actor) }}"
11 |
12 | on:
13 | workflow_dispatch:
14 | inputs:
15 | config:
16 | description: 'A comma separated list of R-hub platforms to use.'
17 | type: string
18 | default: 'linux,windows,macos'
19 | name:
20 | description: 'Run name. You can leave this empty now.'
21 | type: string
22 | id:
23 | description: 'Unique ID. You can leave this empty now.'
24 | type: string
25 |
26 | jobs:
27 |
28 | setup:
29 | runs-on: ubuntu-latest
30 | outputs:
31 | containers: ${{ steps.rhub-setup.outputs.containers }}
32 | platforms: ${{ steps.rhub-setup.outputs.platforms }}
33 |
34 | steps:
35 | # NO NEED TO CHECKOUT HERE
36 | - uses: r-hub/actions/setup@v1
37 | with:
38 | config: ${{ github.event.inputs.config }}
39 | id: rhub-setup
40 |
41 | linux-containers:
42 | needs: setup
43 | if: ${{ needs.setup.outputs.containers != '[]' }}
44 | runs-on: ubuntu-latest
45 | name: ${{ matrix.config.label }}
46 | strategy:
47 | fail-fast: false
48 | matrix:
49 | config: ${{ fromJson(needs.setup.outputs.containers) }}
50 | container:
51 | image: ${{ matrix.config.container }}
52 |
53 | steps:
54 | - uses: r-hub/actions/checkout@v1
55 | - uses: r-hub/actions/platform-info@v1
56 | with:
57 | token: ${{ secrets.RHUB_TOKEN }}
58 | job-config: ${{ matrix.config.job-config }}
59 | - uses: r-hub/actions/setup-deps@v1
60 | with:
61 | token: ${{ secrets.RHUB_TOKEN }}
62 | job-config: ${{ matrix.config.job-config }}
63 | - uses: r-hub/actions/run-check@v1
64 | with:
65 | token: ${{ secrets.RHUB_TOKEN }}
66 | job-config: ${{ matrix.config.job-config }}
67 |
68 | other-platforms:
69 | needs: setup
70 | if: ${{ needs.setup.outputs.platforms != '[]' }}
71 | runs-on: ${{ matrix.config.os }}
72 | name: ${{ matrix.config.label }}
73 | strategy:
74 | fail-fast: false
75 | matrix:
76 | config: ${{ fromJson(needs.setup.outputs.platforms) }}
77 |
78 | steps:
79 | - uses: r-hub/actions/checkout@v1
80 | - uses: r-hub/actions/setup-r@v1
81 | with:
82 | job-config: ${{ matrix.config.job-config }}
83 | token: ${{ secrets.RHUB_TOKEN }}
84 | - uses: r-hub/actions/platform-info@v1
85 | with:
86 | token: ${{ secrets.RHUB_TOKEN }}
87 | job-config: ${{ matrix.config.job-config }}
88 | - uses: r-hub/actions/setup-deps@v1
89 | with:
90 | job-config: ${{ matrix.config.job-config }}
91 | token: ${{ secrets.RHUB_TOKEN }}
92 | - uses: r-hub/actions/run-check@v1
93 | with:
94 | job-config: ${{ matrix.config.job-config }}
95 | token: ${{ secrets.RHUB_TOKEN }}
96 |
--------------------------------------------------------------------------------
/.github/workflows/test-acceptance.yaml:
--------------------------------------------------------------------------------
1 | # Workflow derived from https://github.com/r-lib/actions/tree/v2/examples
2 | # Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help
3 | on:
4 | push:
5 | branches: [main, master]
6 | pull_request:
7 |
8 | name: test-acceptance.yaml
9 |
10 | permissions: read-all
11 |
12 | jobs:
13 | test-coverage:
14 | runs-on: ubuntu-latest
15 | env:
16 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
17 |
18 | steps:
19 | - uses: actions/checkout@v4
20 |
21 | - uses: r-lib/actions/setup-r@v2
22 | with:
23 | use-public-rspm: true
24 |
25 | - uses: r-lib/actions/setup-r-dependencies@v2
26 |
27 | - name: Test acceptance
28 | run: |
29 | pkgload::load_all()
30 | cucumber::test()
31 | shell: Rscript {0}
32 |
--------------------------------------------------------------------------------
/.github/workflows/test-coverage.yaml:
--------------------------------------------------------------------------------
1 | # Workflow derived from https://github.com/r-lib/actions/tree/v2/examples
2 | # Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help
3 | on:
4 | push:
5 | branches: [main, master]
6 | pull_request:
7 |
8 | name: test-coverage.yaml
9 |
10 | permissions: read-all
11 |
12 | jobs:
13 | test-coverage:
14 | runs-on: ubuntu-latest
15 | env:
16 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
17 |
18 | steps:
19 | - uses: actions/checkout@v4
20 |
21 | - uses: r-lib/actions/setup-r@v2
22 | with:
23 | use-public-rspm: true
24 |
25 | - uses: r-lib/actions/setup-r-dependencies@v2
26 | with:
27 | extra-packages: any::covr, any::xml2
28 | needs: coverage
29 |
30 | - name: Test coverage
31 | run: |
32 | cov <- covr::package_coverage(
33 | quiet = FALSE,
34 | clean = FALSE,
35 | install_path = file.path(normalizePath(Sys.getenv("RUNNER_TEMP"), winslash = "/"), "package")
36 | )
37 | print(cov)
38 | covr::to_cobertura(cov)
39 | shell: Rscript {0}
40 |
41 | - uses: codecov/codecov-action@v5
42 | with:
43 | # Fail if error if not on PR, or if on PR and token is given
44 | fail_ci_if_error: ${{ github.event_name != 'pull_request' || secrets.CODECOV_TOKEN }}
45 | files: ./cobertura.xml
46 | plugins: noop
47 | disable_search: true
48 | token: ${{ secrets.CODECOV_TOKEN }}
49 |
50 | - name: Show testthat output
51 | if: always()
52 | run: |
53 | ## --------------------------------------------------------------------
54 | find '${{ runner.temp }}/package' -name 'testthat.Rout*' -exec cat '{}' \; || true
55 | shell: bash
56 |
57 | - name: Upload test results
58 | if: failure()
59 | uses: actions/upload-artifact@v4
60 | with:
61 | name: coverage-test-failures
62 | path: ${{ runner.temp }}/package
63 |
--------------------------------------------------------------------------------
/.github/workflows/test-mutation.yaml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | branches:
4 | - main
5 |
6 | name: test-mutation.yml
7 | permissions: write-all
8 |
9 | jobs:
10 | generate-badge:
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v3
15 | with:
16 | fetch-depth: 0
17 |
18 | - uses: r-lib/actions/setup-r@v2
19 | with:
20 | use-public-rspm: true
21 |
22 | - uses: r-lib/actions/setup-r-dependencies@v2
23 | with:
24 | extra-packages: any::jsonlite
25 |
26 | - name: Set up Git user
27 | run: |
28 | git config user.name "github-actions"
29 | git config user.email "github-actions@github.com"
30 |
31 | - name: Update badge on badges branch to show "NA"
32 | run: |
33 | # Save current branch name
34 | CURRENT_BRANCH=$(git symbolic-ref --short HEAD)
35 |
36 | # Try to fetch the badges branch
37 | git fetch origin badges || echo "No badges branch yet, will create it"
38 |
39 | # If badges branch exists remotely, check it out
40 | if git show-ref --verify --quiet refs/remotes/origin/badges; then
41 | echo "Remote badges branch exists, checking it out"
42 | git checkout -b badges origin/badges || git checkout badges
43 | else
44 | # Create a new orphan branch
45 | echo "Creating new badges branch"
46 | git checkout --orphan badges
47 | # Remove all files from staging
48 | git rm -rf --cached .
49 | # Create empty commit to start the branch
50 | git commit --allow-empty -m "Initial empty commit for badges branch"
51 | fi
52 |
53 | # Create badges directory
54 | mkdir -p .badges
55 |
56 | # Create temporary NA badge
57 | echo '{
58 | "schemaVersion": 1,
59 | "label": "muttest",
60 | "labelColor": "#0f2a13",
61 | "message": "NA",
62 | "color": "#858585",
63 | "logoSvg": ""
64 | }' > .badges/muttest.json
65 |
66 | # Add, commit and push
67 | git add .badges/muttest.json
68 | git commit -m "chore: :gears: Set badge message to NA before testing" || echo "No changes to commit"
69 | git push --force-with-lease origin badges
70 |
71 | # Return to the original branch
72 | git checkout $CURRENT_BRANCH
73 |
74 | - name: Mutation testing
75 | shell: Rscript {0}
76 | run: |
77 | dir.create(".badges", showWarnings = FALSE)
78 | color_scale <- function(score) {
79 | rgb <- round(colorRamp(c("#D61F1F", "#FFD301", "#006B3D"))(score))
80 | sprintf("#%02X%02X%02X", rgb[1], rgb[2], rgb[3])
81 | }
82 |
83 | create_badge <- function(score) {
84 | jsonlite::write_json(
85 | list(
86 | schemaVersion = 1,
87 | label = "muttest",
88 | labelColor = "#0f2a13",
89 | message = paste0(round(score, digits = 2) * 100, "%"),
90 | color = color_scale(score),
91 | logoSvg = ""
92 | ),
93 | path = ".badges/muttest.json",
94 | auto_unbox = TRUE
95 | )
96 | }
97 |
98 | pkgload::load_all()
99 |
100 | plan <- plan(
101 | mutators = list(
102 | operator("+", "-"),
103 | operator("-", "+"),
104 | operator("*", "/"),
105 | operator("/", "*"),
106 | operator("==", "!="),
107 | operator("!=", "=="),
108 | operator("<", ">"),
109 | operator(">", "<"),
110 | operator("<=", ">="),
111 | operator(">=", "<=")
112 | )
113 | )
114 | score <- muttest(plan)
115 |
116 | create_badge(score)
117 |
118 | - name: Update badge with test results
119 | run: |
120 | # Save the badge file to a temporary location
121 | mkdir -p /tmp/muttest-badge
122 | mv .badges/muttest.json /tmp/muttest-badge/
123 |
124 | # Fetch and checkout badges branch
125 | git fetch origin badges
126 | git checkout badges
127 |
128 | # Copy the badge file from temp location to badges directory
129 | cp /tmp/muttest-badge/muttest.json .badges/
130 |
131 | # Add, commit and push
132 | git add .badges/muttest.json
133 | git commit -m "chore: :gears: Update muttest badge with test results" || echo "No changes to commit"
134 | git push --force-with-lease origin badges
135 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .Rproj.user
2 | .Rhistory
3 | .RData
4 | .Ruserdata
5 | .Renviron
6 | docs
7 |
--------------------------------------------------------------------------------
/.lintr:
--------------------------------------------------------------------------------
1 | linters: linters_with_defaults(
2 | line_length_linter = line_length_linter(120),
3 | object_name_linter = object_name_linter(styles = c("snake_case", "CamelCase"))
4 | )
5 | encoding: "UTF-8"
6 |
--------------------------------------------------------------------------------
/DESCRIPTION:
--------------------------------------------------------------------------------
1 | Package: muttest
2 | Type: Package
3 | Title: Mutation Testing
4 | Version: 0.1.0
5 | Authors@R: c(
6 | person(
7 | "Jakub", "Sobolewski",
8 | email = "jakupsob@gmail.com",
9 | role = c("aut", "cre")
10 | )
11 | )
12 | Description: Measure quality of your tests.
13 | 'muttest' introduces small changes (mutations) to your code
14 | and runs your tests to check if they catch the changes.
15 | If they do, your tests are good.
16 | If not, your assertions are not specific enough.
17 | 'muttest' gives you percent score of how often your tests catch the changes.
18 | License: MIT + file LICENSE
19 | Encoding: UTF-8
20 | RoxygenNote: 7.3.2
21 | Depends:
22 | R (>= 4.1.0)
23 | Imports:
24 | checkmate,
25 | cli,
26 | dplyr,
27 | fs,
28 | purrr,
29 | R6,
30 | rlang,
31 | testthat,
32 | tibble,
33 | treesitter,
34 | treesitter.r,
35 | withr
36 | Config/testthat/edition: 3
37 | URL: https://jakubsob.github.io/muttest/
38 | Suggests:
39 | box,
40 | covr,
41 | cucumber (>= 2.1.0),
42 | ggplot2,
43 | shiny
44 | Config/Needs/website: rmarkdown
45 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | YEAR: 2025
2 | COPYRIGHT HOLDER: muttest authors
3 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # MIT License
2 |
3 | Copyright (c) 2025 muttest authors
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/NAMESPACE:
--------------------------------------------------------------------------------
1 | # Generated by roxygen2: do not edit by hand
2 |
3 | export(CopyStrategy)
4 | export(FileTestStrategy)
5 | export(FullTestStrategy)
6 | export(MutationReporter)
7 | export(PackageCopyStrategy)
8 | export(ProgressMutationReporter)
9 | export(TestStrategy)
10 | export(default_copy_strategy)
11 | export(default_reporter)
12 | export(default_test_strategy)
13 | export(muttest)
14 | export(operator)
15 | export(plan)
16 | import(testthat)
17 | importFrom(R6,R6Class)
18 | importFrom(cli,col_green)
19 | importFrom(cli,col_grey)
20 | importFrom(cli,col_red)
21 | importFrom(cli,col_yellow)
22 | importFrom(cli,symbol)
23 | importFrom(rlang,.data)
24 | importFrom(rlang,`%||%`)
25 |
--------------------------------------------------------------------------------
/NEWS.md:
--------------------------------------------------------------------------------
1 | # 0.1.0
2 |
3 | - ✨ Create a testing plan with `plan`.
4 | - ✨ Run mutation testing with `muttest`.
5 | - ✨ Support mutating operators with `operator`.
6 | - ✨ Control copying project to temporary directory with `CopyStrategy`:
7 | - `PackageCopyStrategy` implemented for copying package files.
8 | - ✨ Control test execution for each mutant with `TestStrategy`:
9 | - `FullTestStrategy` for running all tests for each mutant.
10 | - `FileTestStrategy` for running only test files matching mutant files.
11 | - ✨ See test results with `MutationReporter`.
12 | - `ProgressMutationReporter` for printing progress to the console.
13 |
--------------------------------------------------------------------------------
/R/mutator-operator.R:
--------------------------------------------------------------------------------
1 | Mutator <- R6::R6Class(
2 | public = list(
3 | from = NULL,
4 | to = NULL,
5 | query = NULL,
6 | initialize = function(from, to, query) {
7 | self$from <- from
8 | self$to <- to
9 | self$query <- query
10 | },
11 | mutate = function(code) {
12 | mutate_code(code, self)
13 | },
14 | # nocov start
15 | print = function() {
16 | cat(sprintf("Mutator: %s -> %s\n", self$from, self$to))
17 | cat(sprintf("Query: %s\n", self$query))
18 | }
19 | # nocov end
20 | )
21 | )
22 |
23 | #' Mutate an operator
24 | #'
25 | #' It changes a binary operator to another one.
26 | #'
27 | #' @examples
28 | #' operator("==", "!=")
29 | #' operator(">", "<")
30 | #' operator("<", ">")
31 | #' operator("+", "-")
32 | #'
33 | #' @param from The operator to be replaced.
34 | #' @param to The operator to replace with.
35 | #' @export
36 | operator <- function(from, to) {
37 | Mutator$new(
38 | from = from,
39 | to = to,
40 | query = sprintf('(binary_operator
41 | lhs: (_) @lhs
42 | operator: _ @operator
43 | rhs: (_) @rhs
44 | (#eq? @operator "%s")
45 | )', from)
46 | )
47 | }
48 |
49 | info_oneline <- function(m) {
50 | paste(m$from, cli::symbol$arrow_right, m$to)
51 | }
52 |
53 | replace <- function(code, node, mutator) {
54 | start_point <- treesitter::node_start_point(node)
55 | code[start_point$row + 1] <- paste0(
56 | substr(code[start_point$row + 1], 1, start_point$column),
57 | mutator$to,
58 | substr(
59 | code[start_point$row + 1],
60 | start_point$column + nchar(mutator$from) + 1,
61 | nchar(code[start_point$row + 1])
62 | )
63 | )
64 | code
65 | }
66 |
67 | mutate_code <- function(code, mutator) {
68 | language <- treesitter.r::language()
69 | parser <- treesitter::parser(language)
70 | treesitter_code <- paste(code, collapse = "\n")
71 | tree <- treesitter::parser_parse(parser, treesitter_code)
72 | root_node <- treesitter::tree_root_node(tree)
73 |
74 | query <- treesitter::query(language, mutator$query)
75 | captures <- treesitter::query_captures(query, root_node)
76 |
77 | if (length(captures$node) == 0) {
78 | return(NULL)
79 | }
80 |
81 | mutations <- list()
82 | for (i in seq_along(captures$node)) {
83 | node <- captures$node[[i]]
84 | if (treesitter::node_text(node) != mutator$from) {
85 | next
86 | }
87 | mutations <- append(
88 | mutations,
89 | list(
90 | replace(code, node, mutator)
91 | )
92 | )
93 | }
94 |
95 | mutations
96 | }
97 |
--------------------------------------------------------------------------------
/R/muttest.R:
--------------------------------------------------------------------------------
1 | #' Run a mutation test
2 | #'
3 | #' @param plan A data frame with the test plan. See `plan()`.
4 | #' @param path Path to the test directory.
5 | #' @param reporter Reporter to use for mutation testing results. See `?MutationReporter`.
6 | #' @param test_strategy Strategy for running tests. See `?TestStrategy`.
7 | #' The purpose of test strategy is to control how tests are executed.
8 | #' We can run all tests for each mutant, or only tests that are relevant to the mutant.
9 | #' @param copy_strategy Strategy for copying the project. See `?CopyStrategy`.
10 | #' This strategy controls which files are copied to the temporary directory, where the tests are run.
11 | #'
12 | #' @return A numeric value representing the mutation score.
13 | #'
14 | #' @export
15 | #' @md
16 | #' @importFrom rlang .data
17 | muttest <- function(
18 | plan,
19 | path = "tests/testthat",
20 | reporter = default_reporter(),
21 | test_strategy = default_test_strategy(),
22 | copy_strategy = default_copy_strategy()
23 | ) {
24 | checkmate::assert_directory_exists(path)
25 | checkmate::assert(
26 | checkmate::check_data_frame(plan),
27 | checkmate::check_set_equal(
28 | c("filename", "original_code", "mutated_code", "mutator"),
29 | names(plan)
30 | ),
31 | combine = "and"
32 | )
33 | checkmate::assert_class(reporter, "MutationReporter")
34 | checkmate::assert_class(test_strategy, "TestStrategy", null.ok = TRUE)
35 | checkmate::assert_class(copy_strategy, "CopyStrategy")
36 |
37 | if (nrow(plan) == 0) {
38 | return(invisible(NA_real_))
39 | }
40 |
41 | reporter$start_reporter(plan)
42 |
43 | plan |>
44 | dplyr::arrange(.data$filename, .data$mutator) |>
45 | dplyr::rowwise() |>
46 | dplyr::group_split() |>
47 | purrr::walk(\(row) {
48 | mutator <- row$mutator[[1]]
49 | filename <- row$filename
50 | mutated_code <- row$mutated_code[[1]]
51 |
52 | reporter$start_file(filename)
53 | reporter$start_mutator(mutator)
54 | reporter$update(force = TRUE)
55 |
56 | dir <- copy_strategy$execute(getwd(), row)
57 | checkmate::assert_directory_exists(dir)
58 | on.exit(fs::dir_delete(dir))
59 | withr::with_tempdir(tmpdir = dir, pattern = "", {
60 | withr::with_dir(dir, {
61 | temp_filename <- file.path(dir, filename)
62 | writeLines(mutated_code, temp_filename)
63 |
64 | test_results <- test_strategy$execute(
65 | path = path,
66 | plan = row,
67 | reporter = reporter$test_reporter
68 | )
69 | checkmate::assert_class(test_results, "testthat_results")
70 | })
71 | })
72 |
73 | test_results_tibble <- tibble::as_tibble(test_results)
74 | killed <- as.numeric(sum(test_results_tibble$failed) > 0)
75 | survived <- as.numeric(sum(test_results_tibble$failed) == 0)
76 | errors <- sum(test_results_tibble$error)
77 |
78 | reporter$add_result(
79 | row,
80 | killed,
81 | survived,
82 | errors
83 | )
84 | reporter$end_mutator()
85 | reporter$end_file()
86 | })
87 |
88 | reporter$end_reporter()
89 | invisible(reporter$get_score())
90 | }
91 |
92 | #' Create a plan for mutation testing
93 | #'
94 | #' Each mutant requires rerunning the tests. For large project it might be not feasible to test all
95 | #' mutants in one go. This function allows you to create a plan for selected source files and mutators.
96 | #'
97 | #' The plan is in a data frame format, where each row represents a mutant.
98 | #'
99 | #' You can subset the plan before passing it to the `muttest()` function.
100 | #'
101 | #' @param mutators A list of mutators to use. See [operator()].
102 | #' @param source_files A vector of file paths to the source files.
103 | #' @return A data frame with the test plan.
104 | #' The data frame has the following columns:
105 | #' - `filename`: The name of the source file.
106 | #' - `original_code`: The original code of the source file.
107 | #' - `mutated_code`: The mutated code of the source file.
108 | #' - `mutator`: The mutator that was applied.
109 | #'
110 | #' @export
111 | #' @md
112 | plan <- function(
113 | mutators,
114 | source_files = fs::dir_ls("R", regexp = ".[rR]$")
115 | ) {
116 | checkmate::assert_file_exists(source_files, extension = c("R", "r"))
117 | checkmate::assert_list(mutators)
118 | map_dfr <- purrr::compose(dplyr::bind_rows, purrr::map)
119 | map_dfr(mutators, function(mutator) {
120 | map_dfr(source_files, function(filename) {
121 | code_lines <- readLines(filename)
122 | mutations <- mutator$mutate(code_lines)
123 | if (length(mutations) == 0) {
124 | return(
125 | tibble::tibble(
126 | filename = character(),
127 | original_code = list(character()),
128 | mutated_code = list(character()),
129 | mutator = list(mutator)
130 | )
131 | )
132 | }
133 | map_dfr(mutations, function(mutation) {
134 | tibble::tibble(
135 | filename = filename,
136 | original_code = list(code_lines),
137 | mutated_code = list(mutation),
138 | mutator = list(mutator)
139 | )
140 | })
141 | })
142 | })
143 | }
144 |
--------------------------------------------------------------------------------
/R/project_copy_strategy.R:
--------------------------------------------------------------------------------
1 | #' @title CopyStrategy interface
2 | #'
3 | #' @description
4 | #' Extend this class to implement a custom copy strategy.
5 | #'
6 | #' @md
7 | #' @export
8 | #' @family CopyStrategy
9 | CopyStrategy <- R6::R6Class(
10 | classname = "CopyStrategy",
11 | public = list(
12 | #' @description
13 | #' Copy project files according to the strategy
14 | #'
15 | #' @param original_dir The original directory to copy from
16 | #' @param plan The current test plan
17 | #' @return The path to the temporary directory
18 | execute = function(original_dir) {
19 | rlang::abort("Not implemented")
20 | }
21 | )
22 | )
23 |
24 | #' @title Package copy strategy
25 | #'
26 | #' @description
27 | #' It copies all files and directories from the original directory to a temporary directory.
28 | #'
29 | #' @md
30 | #' @export
31 | #' @family CopyStrategy
32 | PackageCopyStrategy <- R6::R6Class(
33 | classname = "PackageCopyStrategy",
34 | inherit = CopyStrategy,
35 | public = list(
36 | #' @description
37 | #' Copy project files, excluding hidden and temp directories
38 | #'
39 | #' @param original_dir The original directory to copy from
40 | #' @param plan The current test plan
41 | #' @return The path to the temporary directory
42 | execute = function(original_dir, plan) {
43 | temp_dir <- fs::path(tempdir(), digest::digest(plan))
44 |
45 | dirs_to_copy <- list.dirs(
46 | original_dir,
47 | recursive = FALSE,
48 | full.names = FALSE
49 | )
50 | dirs_to_copy <- dirs_to_copy[!grepl("^\\.|tmp|temp", dirs_to_copy)]
51 |
52 | purrr::walk(dirs_to_copy, function(dir) {
53 | src_path <- file.path(original_dir, dir)
54 | if (dir.exists(src_path)) {
55 | fs::dir_copy(
56 | src_path,
57 | file.path(temp_dir, dir),
58 | overwrite = TRUE
59 | )
60 | }
61 | })
62 |
63 | files <- fs::dir_ls(original_dir, type = "file")
64 | files <- fs::path_rel(files, original_dir)
65 | purrr::walk(files, function(x) {
66 | fs::file_copy(
67 | x,
68 | file.path(temp_dir, x),
69 | overwrite = TRUE
70 | )
71 | })
72 |
73 | temp_dir
74 | }
75 | )
76 | )
77 |
78 | #' Create a default project copy strategy
79 | #'
80 | #' @param ... Arguments passed to the `?PackageCopyStrategy` constructor.
81 | #' @return A `?CopyStrategy` object
82 | #' @md
83 | #' @export
84 | #' @family CopyStrategy
85 | default_copy_strategy <- function(...) {
86 | PackageCopyStrategy$new(...)
87 | }
88 |
--------------------------------------------------------------------------------
/R/reporter-progress.R:
--------------------------------------------------------------------------------
1 | #' @title Progress Reporter for Mutation Testing
2 | #'
3 | #' @description
4 | #' A reporter that displays a progress indicator for mutation tests.
5 | #' It provides real-time feedback on which mutants are being tested and whether they were killed by tests.
6 | #'
7 | #' @field start_time Time when testing started (for duration calculation)
8 | #' @field min_time Minimum test duration to display timing information
9 | #' @field col_config List of column configuration for report formatting
10 | #'
11 | #' @importFrom R6 R6Class
12 | #' @importFrom cli col_green col_red col_yellow col_grey symbol
13 | #' @md
14 | #' @export
15 | #' @family MutationReporter
16 | ProgressMutationReporter <- R6::R6Class(
17 | classname = "ProgressMutationReporter",
18 | inherit = MutationReporter,
19 | public = list(
20 | start_time = NULL,
21 | min_time = 1,
22 | col_config = list(
23 | "status" = list(
24 | padding_left = 0,
25 | padding_right = 1,
26 | width = 2
27 | ),
28 | "k" = list(
29 | padding_left = 1,
30 | padding_right = 1,
31 | width = 5,
32 | type = "number"
33 | ),
34 | "s" = list(
35 | padding_left = 1,
36 | padding_right = 1,
37 | width = 5,
38 | type = "number"
39 | ),
40 | "e" = list(
41 | padding_left = 1,
42 | padding_right = 1,
43 | width = 5,
44 | type = "number"
45 | ),
46 | "t" = list(
47 | padding_left = 1,
48 | padding_right = 1,
49 | width = 5,
50 | type = "number"
51 | ),
52 | "score" = list(
53 | padding_left = 1,
54 | padding_right = 1,
55 | width = 5,
56 | type = "number"
57 | ),
58 | "mutator" = list(
59 | padding_left = 1,
60 | padding_right = 1,
61 | width = 10
62 | ),
63 | "file" = list(
64 | padding_left = 1,
65 | padding_right = 1
66 | )
67 | ),
68 |
69 | #' @description Format a column with specified padding and width
70 | #' @param text Text to format
71 | #' @param col_name Column name to use configuration from
72 | #' @param colorize Optional function to color the text
73 | format_column = function(text, col_name, colorize = NULL) {
74 | config <- self$col_config[[col_name]]
75 |
76 | # Get actual visible length of text
77 | text_len <- nchar(text)
78 |
79 | # If width is specified, calculate available text space after padding
80 | if (!is.null(config$width) && config$width > 0) {
81 | padding_size <- (config$padding_left %||% 0) +
82 | (config$padding_right %||% 0)
83 | text_max_width <- config$width - padding_size
84 | if (text_len > text_max_width && text_max_width > 0) {
85 | # Truncate if text is too long for the available space
86 | text <- substring(text, 1, text_max_width)
87 | text_len <- text_max_width
88 | }
89 | } else if (!is.null(config$max_width) && config$max_width > 0) {
90 | # Otherwise, use the existing max_width logic if provided
91 | text <- substring(text, 1, config$max_width)
92 | text_len <- min(text_len, config$max_width)
93 | }
94 |
95 | # Calculate left padding
96 | left_pad <- ""
97 | if (!is.null(config$padding_left) && config$padding_left > 0) {
98 | left_pad <- strrep(" ", config$padding_left)
99 | }
100 |
101 | # Calculate right padding
102 | right_pad <- ""
103 | if (!is.null(config$padding_right) && config$padding_right > 0) {
104 | right_pad <- strrep(" ", config$padding_right)
105 | }
106 |
107 | # Handle fixed width columns
108 | if (!is.null(config$width) && config$width > 0) {
109 | # Calculate current total width
110 | total_width <- text_len + nchar(left_pad) + nchar(right_pad)
111 | if (total_width < config$width) {
112 | # For number columns, add extra spaces to left padding for right alignment
113 | if (!is.null(config$type) && config$type == "number") {
114 | left_pad <- paste0(
115 | left_pad,
116 | strrep(" ", config$width - total_width)
117 | )
118 | } else {
119 | # Otherwise add to right padding (left alignment)
120 | right_pad <- paste0(
121 | right_pad,
122 | strrep(" ", config$width - total_width)
123 | )
124 | }
125 | }
126 | } else if (!is.null(config$max_width) && config$max_width > 0) {
127 | # Handle max_width padding (existing behavior)
128 | total_current_width <- text_len + nchar(left_pad) + nchar(right_pad)
129 | if (total_current_width < config$max_width) {
130 | # Add extra spaces to reach max width
131 | right_pad <- paste0(
132 | right_pad,
133 | strrep(" ", config$max_width - total_current_width)
134 | )
135 | }
136 | }
137 |
138 | # Apply color formatting after padding calculation but before concatenation
139 | if (!is.null(colorize) && is.function(colorize)) {
140 | text <- colorize(text)
141 | }
142 |
143 | paste0(left_pad, text, right_pad)
144 | },
145 |
146 | #' @description Format the header of the report
147 | fmt_h = function() {
148 | paste0(
149 | " ",
150 | " |",
151 | self$format_column("K", "k", cli::col_green),
152 | "|",
153 | self$format_column("S", "s", cli::col_red),
154 | "|",
155 | self$format_column("E", "e", cli::col_yellow),
156 | "|",
157 | self$format_column("T", "t"),
158 | "|",
159 | self$format_column(" %", "score"),
160 | "|",
161 | self$format_column("Mutator", "mutator"),
162 | "|",
163 | self$format_column("File", "file")
164 | )
165 | },
166 |
167 | #' @description Format a row of the report
168 | #' @param status Status symbol (e.g., tick or cross)
169 | #' @param k Number of killed mutations
170 | #' @param s Number of survived mutations
171 | #' @param e Number of errors
172 | #' @param t Total number of mutations
173 | #' @param score Score percentage
174 | #' @param mutator The mutator used
175 | #' @param file The file being tested
176 | #' @return Formatted row string
177 | fmt_r = function(status, k, s, e, t, score, mutator, file) {
178 | paste0(
179 | status,
180 | " |",
181 | self$format_column(as.character(k), "k"),
182 | "|",
183 | self$format_column(as.character(s), "s"),
184 | "|",
185 | self$format_column(as.character(e), "e"),
186 | "|",
187 | self$format_column(as.character(t), "t"),
188 | "|",
189 | self$format_column(score, "score"),
190 | "|",
191 | self$format_column(as.character(mutator), "mutator"),
192 | "|",
193 | self$format_column(file, "file")
194 | )
195 | },
196 |
197 | #' @description Initialize a new progress reporter
198 | #' @param test_reporter Reporter to use for testthat::test_dir
199 | #' @param min_time Minimum time to show elapsed time (default: 1s)
200 | #' @param file Output destination (default: stdout)
201 | initialize = function(
202 | test_reporter = "silent",
203 | min_time = 1,
204 | file = stdout()
205 | ) {
206 | super$initialize(test_reporter, file)
207 |
208 | self$min_time <- min_time
209 | self$results <- list()
210 | },
211 |
212 | #' @description Start reporter
213 | #' @param plan The complete mutation plan
214 | start_reporter = function(plan = NULL) {
215 | super$start_reporter(plan)
216 | self$start_time <- proc.time()
217 | self$results <- list()
218 | self$cat_line(paste(cli::symbol$info, "Mutation Testing"))
219 | self$cat_line(self$fmt_h())
220 | },
221 |
222 | #' @description Add a mutation test result
223 | #' @param plan Current testing plan. See `plan()`.
224 | #' @param killed Whether the mutation was killed by tests
225 | #' @param survived Number of survived mutations
226 | #' @param errors Number of errors encountered
227 | #' @md
228 | add_result = function(
229 | plan,
230 | killed,
231 | survived,
232 | errors
233 | ) {
234 | super$add_result(plan, killed, survived, errors)
235 |
236 | status_symbol <- if (killed) {
237 | cli::col_green(cli::symbol$tick)
238 | } else {
239 | cli::col_red("x")
240 | }
241 |
242 | filename <- plan$filename
243 | mutator <- plan$mutator[[1]]
244 | k <- self$results[[filename]]$killed
245 | s <- self$results[[filename]]$survived
246 | t <- self$results[[filename]]$total
247 | e <- self$results[[filename]]$errors
248 | file_name <- basename(filename)
249 | score <- floor(self$current_score * 100)
250 |
251 | # Format and print the row using our formatting function
252 | self$cat_line(self$fmt_r(
253 | status_symbol,
254 | k,
255 | s,
256 | e,
257 | t,
258 | score,
259 | info_oneline(mutator),
260 | file_name
261 | ))
262 | },
263 |
264 | #' @description Update status spinner (for long-running operations)
265 | #' @param force Force update even if interval hasn't elapsed
266 | update = function(force = FALSE) {
267 | },
268 |
269 | #' @description End testing current file
270 | end_file = function() {
271 | super$end_file()
272 | },
273 |
274 | #' @description Carriage return if dynamic, newline otherwise
275 | # nocov start
276 | cr = function() {
277 | },
278 | # nocov end
279 |
280 | #' @description End reporter with detailed summary
281 | end_reporter = function() {
282 | self$cat_line()
283 |
284 | time <- proc.time() - self$start_time
285 | if (time[[3]] > self$min_time) {
286 | self$cat_line(cli::col_cyan(paste0(
287 | "Duration: ",
288 | sprintf("%.2f s", time[[3]])
289 | )))
290 | }
291 |
292 | results <- dplyr::bind_rows(self$results)
293 | k <- sum(results$killed)
294 | s <- sum(results$survived)
295 | t <- sum(results$total)
296 | e <- sum(results$errors)
297 | score <- self$current_score
298 | self$cat_line()
299 | self$rule(cli::style_bold("Results"))
300 | self$cat_line(
301 | "[ ",
302 | cli::col_green("KILLED "),
303 | k,
304 | " | ",
305 | cli::col_red("SURVIVED "),
306 | s,
307 | " | ",
308 | cli::col_yellow("ERRORS "),
309 | e,
310 | " | ",
311 | "TOTAL ",
312 | t,
313 | " | ",
314 | cli::style_bold(cli::col_green(sprintf("SCORE %.1f%%", score * 100))),
315 | " ]"
316 | )
317 |
318 | self$cat_line()
319 |
320 | super$end_reporter()
321 | }
322 | )
323 | )
324 |
--------------------------------------------------------------------------------
/R/reporter.R:
--------------------------------------------------------------------------------
1 | #' @title Reporter for Mutation Testing
2 | #'
3 | #' @description
4 | #' The job of a mutation reporter is to aggregate and display the results of mutation tests.
5 | #' It tracks each mutation attempt, reporting on whether the tests killed the mutation or the mutation survived.
6 | #'
7 | #' @field test_reporter Reporter to use for the testthat::test_dir function
8 | #' @field out Output destination for reporter messages
9 | #' @field width Width of the console in characters
10 | #' @field unicode Whether Unicode output is supported
11 | #' @field crayon Whether colored output is supported
12 | #' @field rstudio Whether running in RStudio
13 | #' @field hyperlinks Whether terminal hyperlinks are supported
14 | #' @field current_file Path of the file currently being mutated
15 | #' @field current_mutator Mutator currently being applied
16 | #' @field plan Complete mutation plan for the test run
17 | #' @field results List of mutation test results, indexed by file path
18 | #' @field current_score Current score of the mutation tests
19 | #'
20 | #' @md
21 | #' @export
22 | #' @importFrom rlang `%||%`
23 | #' @family MutationReporter
24 | MutationReporter <- R6::R6Class(
25 | classname = "MutationReporter",
26 | public = list(
27 | # Define test reporter that will be used for running tests
28 | test_reporter = NULL,
29 |
30 | # Output destination
31 | out = NULL,
32 |
33 | # Display settings
34 | width = 80,
35 | unicode = TRUE,
36 | crayon = TRUE,
37 | rstudio = TRUE,
38 | hyperlinks = TRUE,
39 |
40 | # Current state
41 | current_file = NULL,
42 | current_mutator = NULL,
43 |
44 | plan = NULL,
45 |
46 | # Track mutations by file
47 | results = NULL,
48 | current_score = NA_real_,
49 |
50 | #' @description Initialize a new reporter
51 | #' @param test_reporter Reporter to use for the testthat::test_dir function
52 | #' @param file Output destination (default: stdout)
53 | initialize = function(test_reporter = "silent", file = stdout()) {
54 | self$test_reporter <- test_reporter
55 | self$out <- file
56 |
57 | # Capture display settings
58 | self$width <- cli::console_width()
59 | self$unicode <- cli::is_utf8_output()
60 | self$crayon <- cli::num_ansi_colors() > 1
61 | self$rstudio <- Sys.getenv("RSTUDIO") == "1"
62 | self$hyperlinks <- cli::ansi_hyperlink_types()[["run"]]
63 | },
64 |
65 | #' @description Start reporter
66 | #' @param plan The complete mutation plan
67 | #' @param temp_dir Path to the temporary directory for testing
68 | start_reporter = function(plan = NULL) {
69 | self$plan <- plan
70 | self$results <- list()
71 | self$current_score <- NA_real_
72 | },
73 |
74 | #' @description Start testing a file
75 | #' @param filename Path to the file being mutated
76 | start_file = function(filename) {
77 | self$current_file <- filename
78 | self$results[[filename]] <- self$results[[filename]] %||%
79 | list(
80 | total = 0,
81 | killed = 0,
82 | survived = 0,
83 | errors = 0
84 | )
85 | },
86 |
87 | #' @description Start testing with a specific mutator
88 | #' @param mutator The mutator being applied
89 | start_mutator = function(mutator) {
90 | self$current_mutator <- mutator
91 | },
92 |
93 | #' @description Add a mutation test result
94 | #' @param plan Current testing plan. See `plan()`.
95 | #' @param killed Whether the mutation was killed by tests
96 | #' @param survived Number of survived mutations
97 | #' @param errors Number of errors encountered
98 | #' @md
99 | add_result = function(
100 | plan,
101 | killed,
102 | survived,
103 | errors
104 | ) {
105 | filename <- plan$filename
106 | self$results[[filename]]$total <- self$results[[filename]]$total + 1
107 | self$results[[filename]]$killed <- self$results[[filename]]$killed +
108 | killed
109 | self$results[[filename]]$survived <- self$results[[filename]]$survived +
110 | survived
111 | self$results[[filename]]$errors <- self$results[[filename]]$errors +
112 | errors
113 | killed_counts <- purrr::map(self$results, "killed")
114 | total_counts <- purrr::map(self$results, "total")
115 | self$current_score <- sum(as.numeric(killed_counts)) /
116 | sum(as.numeric(total_counts))
117 | },
118 |
119 | #' @description End testing with current mutator
120 | end_mutator = function() {
121 | self$current_mutator <- NULL
122 | },
123 |
124 | #' @description End testing current file
125 | end_file = function() {
126 | self$current_file <- NULL
127 | },
128 |
129 | #' @description End reporter and show summary
130 | end_reporter = function() {
131 | },
132 |
133 | #' @description Get the current score
134 | get_score = function() {
135 | self$current_score
136 | },
137 |
138 | #' @description Print a message to the output
139 | #' @param ... Message to print
140 | # nocov start
141 | cat_tight = function(...) {
142 | cat(..., sep = "", file = self$out, append = TRUE)
143 | },
144 | # nocov end
145 |
146 | #' @description Print a message to the output
147 | #' @param ... Message to print
148 | cat_line = function(...) {
149 | cli::cat_line(..., file = self$out)
150 | },
151 |
152 | #' @description Print a message to the output with a rule
153 | #' @param ... Message to print
154 | rule = function(...) {
155 | cli::cat_rule(..., file = self$out)
156 | }
157 | )
158 | )
159 |
160 | #' Create a default reporter
161 | #'
162 | #' @param ... Arguments passed to the `?ProgressMutationReporter` constructor.
163 | #' @md
164 | #' @export
165 | #' @family MutationReporter
166 | default_reporter <- function(...) {
167 | ProgressMutationReporter$new(...)
168 | }
169 |
--------------------------------------------------------------------------------
/R/test_strategy.R:
--------------------------------------------------------------------------------
1 | #' @import testthat
2 | NULL
3 |
4 | #' @title TestStrategy interface
5 | #'
6 | #' @description
7 | #' Extend this class to implement a custom test strategy.
8 | #'
9 | #' @export
10 | #' @md
11 | #' @family TestStrategy
12 | TestStrategy <- R6::R6Class(
13 | classname = "TestStrategy",
14 | public = list(
15 | #' @description Execute the test strategy
16 | #' @param path The path to the test directory
17 | #' @param plan The current mutation plan. See `plan()`.
18 | #' @param reporter The reporter to use for test results
19 | #' @return The test result
20 | execute = function(path, plan, reporter) {
21 | stop("Not implemented")
22 | }
23 | )
24 | )
25 |
26 | #' @title Run all tests for a mutant
27 | #'
28 | #' @description
29 | #' This test strategy tells if a mutant is caught by any test.
30 | #'
31 | #' To get faster results, especially for big codebases, use `?FileTestStrategy` instead.
32 | #'
33 | #' @export
34 | #' @md
35 | #' @family TestStrategy
36 | FullTestStrategy <- R6::R6Class(
37 | classname = "FullTestStrategy",
38 | inherit = TestStrategy,
39 | private = list(
40 | args = list()
41 | ),
42 | public = list(
43 | #' @description Initialize
44 | #' @param load_helpers Whether to load test helpers
45 | #' @param load_package The package loading strategy
46 | initialize = function(
47 | load_helpers = TRUE,
48 | load_package = c("source", "none", "installed")
49 | ) {
50 | private$args <- list(
51 | load_helpers = load_helpers,
52 | load_package = load_package
53 | )
54 | },
55 | #' @description Execute the test strategy
56 | #' @param path The path to the test directory
57 | #' @param plan The current mutation plan. See `plan()`.
58 | #' @param reporter The reporter to use for test results
59 | #' @return The test results
60 | execute = function(path, plan, reporter) {
61 | testthat::test_dir(
62 | path,
63 | stop_on_failure = FALSE,
64 | reporter = reporter,
65 | load_helpers = private$args$load_helpers,
66 | load_package = private$args$load_package
67 | )
68 | }
69 | )
70 | )
71 |
72 | #' @title Run tests matching the mutated source file name
73 | #'
74 | #' @description
75 | #' This strategy tells if a mutant is caught by a test matching the source file name.
76 | #'
77 | #' For example, if the source file name is `foo.R`, and there are test files named `test-foo.R` or `test-bar.R`,
78 | #' only `test-foo.R` will be run.
79 | #'
80 | #' This strategy should give faster results than `?FullTestStrategy`, especially for big codebases,
81 | #' but the score might be less accurate.
82 | #'
83 | #' @export
84 | #' @md
85 | #' @family TestStrategy
86 | FileTestStrategy <- R6::R6Class(
87 | classname = "FileTestStrategy",
88 | inherit = TestStrategy,
89 | private = list(
90 | args = list()
91 | ),
92 | public = list(
93 | #' @description Initialize the FileTestStrategy
94 | #' @param load_helpers Whether to load test helpers
95 | #' @param load_package The package loading strategy
96 | initialize = function(
97 | load_helpers = TRUE,
98 | load_package = c("source", "none", "installed")
99 | ) {
100 | private$args <- list(
101 | load_helpers = load_helpers,
102 | load_package = load_package
103 | )
104 | },
105 | #' @description Execute the test strategy
106 | #' @param path The path to the test directory
107 | #' @param plan The current mutation plan. See `plan()`.
108 | #' @param reporter The reporter to use for test results
109 | #' @return The test results
110 | execute = function(path, plan, reporter) {
111 | file_name <- tools::file_path_sans_ext(basename(plan$filename))
112 | if (!any(grepl(file_name, list.files(path)))) {
113 | return(.empty_test_result())
114 | }
115 | testthat::test_dir(
116 | path = path,
117 | filter = file_name,
118 | stop_on_failure = FALSE,
119 | reporter = reporter,
120 | load_helpers = private$args$load_helpers,
121 | load_package = private$args$load_package
122 | )
123 | }
124 | )
125 | )
126 |
127 | #' @title Create a default run strategy
128 | #'
129 | #' @param ... Arguments passed to the `?FullTestStrategy` constructor.
130 | #' @return A `?TestStrategy` object
131 | #'
132 | #' @export
133 | #' @md
134 | #' @family TestStrategy
135 | default_test_strategy <- function(...) {
136 | FullTestStrategy$new(...)
137 | }
138 |
139 | .empty_test_result <- function() {
140 | structure(
141 | list(),
142 | class = c("testthat_results")
143 | )
144 | }
145 |
--------------------------------------------------------------------------------
/README.Rmd:
--------------------------------------------------------------------------------
1 | ---
2 | output: github_document
3 | ---
4 |
5 |
6 |
7 | ```{r, include = FALSE}
8 | knitr::opts_chunk$set(
9 | collapse = TRUE,
10 | comment = "#>",
11 | fig.path = "man/figures/README-",
12 | out.width = "100%",
13 | root.dir = system.file("examples", "operators", package = "muttest")
14 | )
15 |
16 | include_file <- function(file, filename) {
17 | code <- readLines(file)
18 | cat("```r", paste("#'", filename), code, "```", sep = "\n")
19 | }
20 | ```
21 | # muttest
22 |
23 |
24 | [](https://CRAN.R-project.org/package=muttest)
25 | [](https://github.com/jakubsob/muttest/actions/workflows/R-CMD-check.yaml)
26 | [](https://app.codecov.io/gh/jakubsob/muttest)
27 | [](https://github.com/jakubsob/muttest/actions/workflows/test-acceptance.yaml)
28 | [](https://github.com/jakubsob/muttest/actions/workflows/test-mutation.yaml)
29 | [](https://cran.r-project.org/package=muttest)
30 | [](https://cran.r-project.org/package=muttest)
31 |
32 |
33 | Measure quality of your tests with **{muttest}**.
34 |
35 | [covr](https://github.com/r-lib/covr) tells you how much of your code is executed by tests, but it tells you nothing about the quality of those tests.
36 |
37 | In fact, you can have tests with zero assertions and still get 100% coverage. That can give a false sense of security. Mutation testing addresses this gap.
38 |
39 | It works like this:
40 |
41 | - Define a set of code changes (mutations).
42 | - Run your test suite against mutated versions of your source code.
43 | - Measure how often the mutations are caught (i.e., cause test failures).
44 |
45 | This reveals whether your tests are asserting the right things:
46 |
47 | - 0% score → Your tests pass no matter what changes. Your assertions are weak.
48 | - 100% score → Every mutation triggers a test failure. Your tests are robust.
49 |
50 | {muttest} not only gives you the score, but it also tells you tests for which files require improved assertions.
51 |
52 | # Example
53 |
54 | Given our codebase is:
55 |
56 | ```{r}
57 | #| echo: false
58 | #| results: asis
59 | include_file(
60 | system.file("examples", "operators", "R", "calculate.R", package = "muttest"),
61 | "R/calculate.R"
62 | )
63 | ```
64 |
65 | And our tests are:
66 |
67 | ```{r}
68 | #| echo: false
69 | #| results: asis
70 | include_file(
71 | system.file("examples", "operators", "tests", "testthat", "test-calculate.R", package = "muttest"),
72 | "tests/testthat/test_calculate.R"
73 | )
74 | ```
75 |
76 |
77 | When running `muttest::muttest()` we'll get a report of the mutation score:
78 |
79 | ```{r}
80 | #| eval: false
81 | plan <- muttest::plan(
82 | source_files = "R/calculate.R",
83 | mutators = list(
84 | muttest::operator("+", "-"),
85 | muttest::operator("*", "/")
86 | )
87 | )
88 |
89 | muttest::muttest(plan)
90 | #> ℹ Mutation Testing
91 | #> | K | S | E | T | % | Mutator | File
92 | #> x | 0 | 1 | 0 | 1 | 0 | + → - | calculate.R
93 | #> ✔ | 1 | 1 | 0 | 2 | 50 | * → / | calculate.R
94 | #> ── Mutation Testing Results ────────────────────────────────────────────────────
95 | #> [ KILLED 1 | SURVIVED 1 | ERRORS 0 | TOTAL 2 | SCORE 50.0% ]
96 | ```
97 |
98 | The mutation score is: $\text{Mutation Score} = \frac{\text{Killed Mutants}}{\text{Total Mutants}} \times 100\%$, where a Mutant is defined as variant of the original code that is used to test the robustness of the test suite.
99 |
100 | In the example there were 2 mutants of the code:
101 |
102 | ```r
103 | #' R/calculate.R
104 | calculate <- function(x, y) {
105 | (x - y) * 0 # mutant 1: "+" -> "-"
106 | }
107 | ```
108 |
109 | ```r
110 | #' R/calculate.R
111 | calculate <- function(x, y) {
112 | (x + y) / 0 # mutant 2: "*" -> "/"
113 | }
114 | ```
115 |
116 | Tests are run against both variants of the code.
117 |
118 | The first test run against the first mutant will pass, because the result is still 0. The second test run against the second mutant will fail, because the result is Inf.
119 |
120 | The second test will pass against both mutants, because the result is still numeric.
121 |
122 | ```r
123 | #' tests/testthat/test_calculate.R
124 | test_that("calculate always returns 0", {
125 | # 🟢 This test doesn't kill "+" -> "-" operator mutant: (2 - 2) * 0 = 0
126 | # ❌ This test kills "*" -> "/" operator mutant: (2 + 2) / 0 = Inf
127 | expect_equal(calculate(2, 2), 0)
128 | })
129 |
130 | test_that("calculate returns a numeric", {
131 | # 🟢 This test doesn't kill "+" -> "-", (2 - 2) * 0 = 0, is numeric
132 | # 🟢 This test doesn't kill "*" -> "/", (2 + 2) / 0 = Inf, is numeric
133 | expect_true(is.numeric(calculate(2, 2)))
134 | })
135 | ```
136 |
137 | We have killed 1 mutant out of 2, so the mutation score is 50%.
138 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | # muttest
5 |
6 |
7 |
8 | [](https://CRAN.R-project.org/package=muttest)
10 | [](https://github.com/jakubsob/muttest/actions/workflows/R-CMD-check.yaml)
11 | [](https://app.codecov.io/gh/jakubsob/muttest)
13 | [](https://github.com/jakubsob/muttest/actions/workflows/test-acceptance.yaml)
14 | [](https://github.com/jakubsob/muttest/actions/workflows/test-mutation.yaml)
15 | [](https://cran.r-project.org/package=muttest)
17 | [](https://cran.r-project.org/package=muttest)
19 |
20 |
21 | Measure quality of your tests with **{muttest}**.
22 |
23 | [covr](https://github.com/r-lib/covr) tells you how much of your code is
24 | executed by tests, but it tells you nothing about the quality of those
25 | tests.
26 |
27 | In fact, you can have tests with zero assertions and still get 100%
28 | coverage. That can give a false sense of security. Mutation testing
29 | addresses this gap.
30 |
31 | It works like this:
32 |
33 | - Define a set of code changes (mutations).
34 | - Run your test suite against mutated versions of your source code.
35 | - Measure how often the mutations are caught (i.e., cause test
36 | failures).
37 |
38 | This reveals whether your tests are asserting the right things:
39 |
40 | - 0% score → Your tests pass no matter what changes. Your assertions are
41 | weak.
42 | - 100% score → Every mutation triggers a test failure. Your tests are
43 | robust.
44 |
45 | {muttest} not only gives you the score, but it also tells you tests for
46 | which files require improved assertions.
47 |
48 | # Example
49 |
50 | Given our codebase is:
51 |
52 | ``` r
53 | #' R/calculate.R
54 | calculate <- function(x, y) {
55 | (x + y) * 0
56 | }
57 | ```
58 |
59 | And our tests are:
60 |
61 | ``` r
62 | #' tests/testthat/test_calculate.R
63 | test_that("calculate returns a numeric", {
64 | expect_true(is.numeric(calculate(2, 2))) # ❌ This assertion doesn't kill mutants
65 | })
66 |
67 | test_that("calculate always returns 0", {
68 | expect_equal(calculate(2, 2), 0) # ✅ This assertion only kills "*" -> "/" mutant
69 | })
70 | ```
71 |
72 | When running `muttest::muttest()` we’ll get a report of the mutation
73 | score:
74 |
75 | ``` r
76 | plan <- muttest::plan(
77 | source_files = "R/calculate.R",
78 | mutators = list(
79 | muttest::operator("+", "-"),
80 | muttest::operator("*", "/")
81 | )
82 | )
83 |
84 | muttest::muttest(plan)
85 | #> ℹ Mutation Testing
86 | #> | K | S | E | T | % | Mutator | File
87 | #> x | 0 | 1 | 0 | 1 | 0 | + → - | calculate.R
88 | #> ✔ | 1 | 1 | 0 | 2 | 50 | * → / | calculate.R
89 | #> ── Mutation Testing Results ────────────────────────────────────────────────────
90 | #> [ KILLED 1 | SURVIVED 1 | ERRORS 0 | TOTAL 2 | SCORE 50.0% ]
91 | ```
92 |
93 | The mutation score is:
94 | $\text{Mutation Score} = \frac{\text{Killed Mutants}}{\text{Total Mutants}} \times 100\%$,
95 | where a Mutant is defined as variant of the original code that is used
96 | to test the robustness of the test suite.
97 |
98 | In the example there were 2 mutants of the code:
99 |
100 | ``` r
101 | #' R/calculate.R
102 | calculate <- function(x, y) {
103 | (x - y) * 0 # mutant 1: "+" -> "-"
104 | }
105 | ```
106 |
107 | ``` r
108 | #' R/calculate.R
109 | calculate <- function(x, y) {
110 | (x + y) / 0 # mutant 2: "*" -> "/"
111 | }
112 | ```
113 |
114 | Tests are run against both variants of the code.
115 |
116 | The first test run against the first mutant will pass, because the
117 | result is still 0. The second test run against the second mutant will
118 | fail, because the result is Inf.
119 |
120 | The second test will pass against both mutants, because the result is
121 | still numeric.
122 |
123 | ``` r
124 | #' tests/testthat/test_calculate.R
125 | test_that("calculate always returns 0", {
126 | # 🟢 This test doesn't kill "+" -> "-" operator mutant: (2 - 2) * 0 = 0
127 | # ❌ This test kills "*" -> "/" operator mutant: (2 + 2) / 0 = Inf
128 | expect_equal(calculate(2, 2), 0)
129 | })
130 |
131 | test_that("calculate returns a numeric", {
132 | # 🟢 This test doesn't kill "+" -> "-", (2 - 2) * 0 = 0, is numeric
133 | # 🟢 This test doesn't kill "*" -> "/", (2 + 2) / 0 = Inf, is numeric
134 | expect_true(is.numeric(calculate(2, 2)))
135 | })
136 | ```
137 |
138 | We have killed 1 mutant out of 2, so the mutation score is 50%.
139 |
--------------------------------------------------------------------------------
/_pkgdown.yml:
--------------------------------------------------------------------------------
1 | url: https://jakubsob.github.io/muttest/
2 | template:
3 | bootstrap: 5
4 | light-switch: true
5 | bslib:
6 | bg: '#ffffff'
7 | fg: '#000000'
8 | primary: '#886868'
9 | secondary: '#F77669'
10 | includes:
11 | in_header: |
12 |
13 |
14 |
21 |
22 | navbar:
23 | type: dark
24 | structure:
25 | left: [reference, articles, news]
26 | components:
27 | articles:
28 | text: Articles
29 | menu:
30 | - text: How it works
31 | href: articles/how-it-works.html
32 |
33 | reference:
34 | - title: Run
35 | contents:
36 | - muttest
37 | - plan
38 | - title: Mutators
39 | contents:
40 | - operator
41 | - title: Test run strategy
42 | contents:
43 | - default_test_strategy
44 | - FullTestStrategy
45 | - FileTestStrategy
46 | - TestStrategy
47 | - title: Project copy strategy
48 | contents:
49 | - default_copy_strategy
50 | - PackageCopyStrategy
51 | - CopyStrategy
52 | - title: Test reporting
53 | contents:
54 | - default_reporter
55 | - ProgressMutationReporter
56 | - MutationReporter
57 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | comment: false
2 |
3 | coverage:
4 | status:
5 | project:
6 | default:
7 | target: auto
8 | threshold: 1%
9 | informational: true
10 | patch:
11 | default:
12 | target: auto
13 | threshold: 1%
14 | informational: true
15 |
--------------------------------------------------------------------------------
/cran-comments.md:
--------------------------------------------------------------------------------
1 | Fixed package name in Description field, muttest -> 'muttest'.
2 |
--------------------------------------------------------------------------------
/inst/examples/operators/DESCRIPTION:
--------------------------------------------------------------------------------
1 | Package: example
2 | Version: 0.0.0.9000
3 |
--------------------------------------------------------------------------------
/inst/examples/operators/R/calculate.R:
--------------------------------------------------------------------------------
1 | calculate <- function(x, y) {
2 | (x + y) * 0
3 | }
4 |
--------------------------------------------------------------------------------
/inst/examples/operators/tests/testthat/helper.R:
--------------------------------------------------------------------------------
1 | helper_func <- function() {
2 | }
3 |
--------------------------------------------------------------------------------
/inst/examples/operators/tests/testthat/setup.R:
--------------------------------------------------------------------------------
1 | setup_func <- function() {
2 | }
3 |
--------------------------------------------------------------------------------
/inst/examples/operators/tests/testthat/test-calculate.R:
--------------------------------------------------------------------------------
1 | test_that("calculate returns a numeric", {
2 | expect_true(is.numeric(calculate(2, 2))) # ❌ This assertion doesn't kill mutants
3 | })
4 |
5 | test_that("calculate always returns 0", {
6 | expect_equal(calculate(2, 2), 0) # ✅ This assertion only kills "*" -> "/" mutant
7 | })
8 |
--------------------------------------------------------------------------------
/man/CopyStrategy.Rd:
--------------------------------------------------------------------------------
1 | % Generated by roxygen2: do not edit by hand
2 | % Please edit documentation in R/project_copy_strategy.R
3 | \name{CopyStrategy}
4 | \alias{CopyStrategy}
5 | \title{CopyStrategy interface}
6 | \description{
7 | Extend this class to implement a custom copy strategy.
8 | }
9 | \seealso{
10 | Other CopyStrategy:
11 | \code{\link{PackageCopyStrategy}},
12 | \code{\link{default_copy_strategy}()}
13 | }
14 | \concept{CopyStrategy}
15 | \section{Methods}{
16 | \subsection{Public methods}{
17 | \itemize{
18 | \item \href{#method-CopyStrategy-execute}{\code{CopyStrategy$execute()}}
19 | \item \href{#method-CopyStrategy-clone}{\code{CopyStrategy$clone()}}
20 | }
21 | }
22 | \if{html}{\out{
muttest::MutationReporter$cat_line()muttest::MutationReporter$cat_tight()muttest::MutationReporter$end_mutator()muttest::MutationReporter$get_score()muttest::MutationReporter$rule()muttest::MutationReporter$start_file()muttest::MutationReporter$start_mutator()