├── .editorconfig ├── .github ├── CODEOWNERS ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── enhancement.md │ └── issue.md ├── PULL_REQUEST_TEMPLATE.md ├── actions │ └── install │ │ └── action.yml ├── dependabot.yml ├── linters │ ├── .checkov.yml │ ├── .cspell.json │ └── .jscpd.json └── workflows │ ├── manual-deprecate-versions.yml │ ├── manual-manage-versions.yml │ ├── on-main-push.yml │ ├── on-merged-pull-request.yml │ ├── on-published-release.yml │ ├── on-pull-request.yml │ ├── reusable-build.yml │ └── run-e2e-tests.yml ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── pre-push ├── .jscpd.json ├── .mega-linter.yml ├── .mocharc.json ├── .nycrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── bin ├── dev.cmd ├── dev.js ├── run.cmd └── run.js ├── biome.json ├── commitlint.config.cjs ├── jest.config.js ├── knip.config.ts ├── lychee.toml ├── messages └── apex.mutation.test.run.md ├── package-lock.json ├── package.json ├── src ├── adapter │ ├── apexClassRepository.ts │ └── apexTestRunner.ts ├── commands │ └── apex │ │ └── mutation │ │ └── test │ │ └── run.ts ├── mutator │ ├── baseListener.ts │ ├── boundaryConditionMutator.ts │ ├── incrementMutator.ts │ └── mutationListener.ts ├── reporter │ └── HTMLReporter.ts ├── service │ ├── apexClassValidator.ts │ ├── mutantGenerator.ts │ └── mutationTestingService.ts └── type │ ├── ApexClass.ts │ ├── ApexMutation.ts │ ├── ApexMutationParameter.ts │ └── ApexMutationTestResult.ts ├── test ├── data │ ├── Mutation.cls │ ├── Mutation.cls-meta.xml │ ├── MutationTest.cls │ └── MutationTest.cls-meta.xml ├── integration │ └── run.nut.ts └── unit │ ├── adapter │ ├── apexClassRepository.test.ts │ └── apexTestRunner.test.ts │ ├── mutator │ ├── boundaryConditionMutator.test.ts │ ├── incrementMutator.test.ts │ └── mutationListener.test.ts │ ├── reporter │ └── HTMLReporter.test.ts │ └── service │ ├── apexClassValidator.test.ts │ ├── mutantGenerator.test.ts │ └── mutationTestingService.test.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This is a comment. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # These owners will be the default owners for everything in 5 | # the repo. Unless a later match takes precedence 6 | * @scolladon 7 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | github: [scolladon] 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement request 3 | about: Use this template for tracking new features. 4 | title: '[FEATURE NAME]' 5 | labels: enhancement 6 | assignees: scolladon 7 | --- 8 | 9 | ### Is your proposal related to a problem? 10 | 11 | --- 12 | 13 | 17 | 18 | ### Describe a solution you propose 19 | 20 | --- 21 | 22 | 25 | 26 | ### Describe alternatives you've considered 27 | 28 | --- 29 | 30 | 33 | 34 | ### Additional context 35 | 36 | --- 37 | 38 | 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Log an issue 3 | about: Use this template for tracking new bugs. 4 | title: '[BUG NAME]' 5 | labels: bug 6 | assignees: scolladon 7 | --- 8 | 9 | ## What is the problem? 10 | 11 | --- 12 | 13 | 16 | 17 | ### What is the parameter and the value you used with it? 18 | 19 | 22 | 23 | ### What is the expected result? 24 | 25 | 29 | 30 | ### What is the actual result? 31 | 32 | 36 | 37 | ## Steps to reproduce 38 | 39 | --- 40 | 41 | 46 | 47 | ## Execution context 48 | 49 | --- 50 | 51 | 54 | 55 | **Operating System:** … 56 | 57 | **npm version:** … 58 | 59 | **node version:** … 60 | 61 | **git version:** … 62 | 63 | **sf version:** … 64 | 65 | **apex-mutation-testing plugin version:** … 66 | 67 | ## More information (optional) 68 | 69 | --- 70 | 71 | 74 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | 5 | # Explain your changes 6 | 7 | --- 8 | 9 | 12 | 13 | # Does this close any currently open issues? 14 | 15 | --- 16 | 17 | 21 | 22 | closes # 23 | 24 | - [ ] Jest tests added to cover the fix. 25 | - [ ] NUT tests added to cover the fix. 26 | - [ ] E2E tests added to cover the fix. 27 | 28 | # Any particular element that can be tested locally 29 | 30 | --- 31 | 32 | 35 | 36 | # Any other comments 37 | 38 | --- 39 | 40 | 46 | -------------------------------------------------------------------------------- /.github/actions/install/action.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Install 3 | description: Install dependencies 4 | 5 | runs: 6 | using: composite 7 | steps: 8 | - name: Get cache directory 9 | id: cache-dir 10 | run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT" 11 | shell: bash 12 | 13 | - uses: actions/cache@v4 14 | with: 15 | path: ${{ steps.cache-dir.outputs.dir }} 16 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 17 | 18 | - name: Install dependencies 19 | run: npm ci 20 | shell: bash 21 | env: 22 | HUSKY: '0' # By default do not run HUSKY install 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | commit-message: 9 | prefix: "build" 10 | 11 | - package-ecosystem: "npm" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | versioning-strategy: increase 16 | commit-message: 17 | prefix: "build" 18 | allow: 19 | - dependency-type: "all" 20 | -------------------------------------------------------------------------------- /.github/linters/.checkov.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # You can see all available properties here: https://github.com/bridgecrewio/checkov#configuration-using-a-config-file 3 | quiet: true 4 | skip-check: 5 | - CKV_DOCKER_2 6 | - CKV2_GHA_1 7 | - CKV_GHA_7 8 | -------------------------------------------------------------------------------- /.github/linters/.cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePaths": [ 3 | "**/CHANGELOG.md", 4 | "**/.git/**", 5 | "**/megalinter.yml", 6 | "**/node_modules/**" 7 | ], 8 | "language": "en", 9 | "noConfigSearch": true, 10 | "version": "0.2", 11 | "words": [ 12 | "amannn", 13 | "antlr", 14 | "apexskier", 15 | "autoupdate", 16 | "behaviour", 17 | "brqh", 18 | "codeowners", 19 | "colladon", 20 | "commandsstop", 21 | "commitlint", 22 | "cspell", 23 | "dreamforce", 24 | "instantiator", 25 | "jwalton", 26 | "knip", 27 | "megalinter", 28 | "mjyhjbm", 29 | "mocharc", 30 | "nonoctal", 31 | "nycrc", 32 | "sali", 33 | "scolladon", 34 | "sebastien", 35 | "sébastien", 36 | "sfdevrc", 37 | "sfdx", 38 | "sobject", 39 | "soql", 40 | "testkit", 41 | "thollander", 42 | "threashold", 43 | "tsconfig.json", 44 | "tsconfig", 45 | "wagoid", 46 | "wireit" 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /.github/linters/.jscpd.json: -------------------------------------------------------------------------------- 1 | { 2 | "threshold": 0, 3 | "reporters": ["html", "markdown"], 4 | "ignore": [ 5 | "**/node_modules/**", 6 | "**/.git/**", 7 | "**/*cache*/**", 8 | "**/.github/**", 9 | "**/report/**", 10 | "**/img/**", 11 | "**/test/**" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/manual-deprecate-versions.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Deprecate versions 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | version-expression: 8 | description: version number (semver format) or range to deprecate 9 | required: true 10 | type: string 11 | rationale: 12 | description: explain why this version is deprecated. No message content will un-deprecate the version 13 | type: string 14 | 15 | 16 | jobs: 17 | deprecate: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout sources 21 | uses: actions/checkout@v4 22 | 23 | - name: Setup node 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: 18 27 | registry-url: 'https://registry.npmjs.org' 28 | 29 | - name: Change version 30 | run: npm deprecate apex-mutation-testing@$"${{ github.event.inputs.version-expression }}" "${{ github.event.inputs.rationale }}" 31 | env: 32 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/manual-manage-versions.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Manage versions 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | version-alias: 8 | description: version alias to map to a version number 9 | required: true 10 | type: choice 11 | options: 12 | - stable 13 | - latest 14 | - latest-rc 15 | version-number: 16 | description: version number (semver format) 17 | required: true 18 | default: vX.Y.Z 19 | type: string 20 | 21 | jobs: 22 | add-tag: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout sources 26 | uses: actions/checkout@v4 27 | 28 | - name: Setup node 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: 18 32 | registry-url: 'https://registry.npmjs.org' 33 | 34 | - name: Change version 35 | run: npm dist-tag add apex-mutation-testing@${{ github.event.inputs.version-number }} ${{ github.event.inputs.version-alias }} 36 | env: 37 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | -------------------------------------------------------------------------------- /.github/workflows/on-main-push.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Main 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | paths-ignore: 9 | - "**.md" 10 | 11 | jobs: 12 | build: 13 | uses: ./.github/workflows/reusable-build.yml 14 | secrets: inherit 15 | 16 | prepare-release: 17 | needs: [build] 18 | runs-on: ubuntu-latest 19 | outputs: 20 | release_created: ${{ steps.release.outputs.release_created }} 21 | prs_created: ${{ steps.release.outputs.prs_created }} 22 | version: ${{ steps.release.outputs.version }} 23 | steps: 24 | - uses: googleapis/release-please-action@v4 25 | id: release 26 | with: 27 | token: ${{ secrets.RELEASE_PAT }} 28 | release-type: node 29 | 30 | release: 31 | needs: [prepare-release] 32 | runs-on: ubuntu-latest 33 | if: ${{ needs.prepare-release.outputs.release_created == 'true' }} 34 | steps: 35 | - name: Checkout sources 36 | uses: actions/checkout@v4 37 | 38 | - name: Setup node 39 | uses: actions/setup-node@v4 40 | with: 41 | node-version: 18 42 | registry-url: 'https://registry.npmjs.org' 43 | 44 | - name: Setup dependencies, cache and install 45 | uses: ./.github/actions/install 46 | 47 | - name: Publish to npm 48 | run: npm publish --access public --tag latest-rc 49 | env: 50 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 51 | 52 | test-release: 53 | uses: ./.github/workflows/run-e2e-tests.yml 54 | needs: [prepare-release, release] 55 | with: 56 | channel: ${{ needs.prepare-release.outputs.version }} 57 | secrets: inherit 58 | -------------------------------------------------------------------------------- /.github/workflows/on-merged-pull-request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Package dev version cleaner 3 | 4 | on: 5 | pull_request_target: 6 | branches: 7 | - main 8 | paths-ignore: 9 | - "**.md" 10 | - "img/**" 11 | types: 12 | - closed 13 | 14 | jobs: 15 | clean-npm-dev-version: 16 | if: ${{ github.event.pull_request.merged }} 17 | runs-on: ubuntu-latest 18 | permissions: 19 | pull-requests: write 20 | steps: 21 | - name: Checkout sources 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup node 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: 18 28 | registry-url: 'https://registry.npmjs.org' 29 | 30 | - uses: jwalton/gh-find-current-pr@master 31 | id: pr-number 32 | with: 33 | state: closed 34 | 35 | - name: Set dev channel value 36 | run: | 37 | echo "CURRENT_VERSION=$(jq -r '.version' package.json)" >> "$GITHUB_ENV" 38 | echo "DEV_CHANNEL=dev-${{ steps.pr-number.outputs.pr }}" >> "$GITHUB_ENV" 39 | 40 | - name: Remove dist-tag 41 | run: npm dist-tag rm apex-mutation-testing ${{ env.DEV_CHANNEL }} 42 | env: 43 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 44 | 45 | - name: Deprecate related dev versions 46 | run: | 47 | DEV_VERSIONS=$(npm view apex-mutation-testing versions --json | jq -r '.[]' | grep -E "${{ env.CURRENT_VERSION}}-${{ env.DEV_CHANNEL }}") 48 | [ -n "$DEV_VERSIONS" ] && for DEV_VERSION in ${DEV_VERSIONS}; do npm deprecate "apex-mutation-testing@${DEV_VERSION}" "Deprecated dev version"; done 49 | env: 50 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 51 | 52 | - name: Delete package dev channel PR comment 53 | uses: thollander/actions-comment-pull-request@v3 54 | with: 55 | message: | 56 | Published under `${{ env.DEV_CHANNEL }}` npm channel. 57 | ```sh 58 | $ sf plugins install apex-mutation-testing@${{ env.DEV_CHANNEL }} 59 | ``` 60 | comment-tag: dev-publish 61 | mode: delete 62 | -------------------------------------------------------------------------------- /.github/workflows/on-published-release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Published release communication 3 | 4 | on: 5 | release: 6 | types: 7 | - published 8 | 9 | jobs: 10 | release: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: apexskier/github-release-commenter@v1 14 | with: 15 | GITHUB_TOKEN: ${{ github.token }} 16 | comment-template: | 17 | Shipped in [release `{release_tag}`]({release_link}). 18 | Version `{release_tag}` will be assigned to the `latest` npm channel soon 19 | Install it using either `{release_tag}` or the `latest-rc` npm channel 20 | ```sh 21 | $ sf plugins install apex-mutation-testing@latest-rc 22 | # Or 23 | $ sf plugins install apex-mutation-testing@{release_tag} 24 | ``` 25 | 💡 Enjoying apex-mutation-testing? 26 | Your contribution helps us provide fast support 🚀 and high quality features 🔥 27 | Become a [sponsor](https://github.com/sponsors/scolladon) 💙 28 | Happy zombies detection! 29 | -------------------------------------------------------------------------------- /.github/workflows/on-pull-request.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - main 8 | paths-ignore: 9 | - "**.md" 10 | 11 | # Manage concurrency to stop running jobs and start new ones in case of new commit pushed 12 | concurrency: 13 | group: ${{ github.ref }}-${{ github.workflow }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | commit-lint: 18 | runs-on: ubuntu-latest 19 | if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} 20 | steps: 21 | - name: Checkout sources 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Setup node 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: 18 30 | 31 | - name: Setup dependencies, cache and install 32 | uses: ./.github/actions/install 33 | 34 | - name: Lint commits 35 | uses: wagoid/commitlint-github-action@v6 36 | env: 37 | NODE_PATH: ${{ github.workspace }}/node_modules 38 | continue-on-error: true 39 | 40 | pull-request-lint: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Lint PR 44 | uses: amannn/action-semantic-pull-request@v5 45 | env: 46 | GITHUB_TOKEN: ${{ github.token }} 47 | 48 | npm-lint: 49 | runs-on: ubuntu-latest 50 | steps: 51 | - name: Checkout sources 52 | uses: actions/checkout@v4 53 | 54 | - name: Setup node 55 | uses: actions/setup-node@v4 56 | with: 57 | node-version: 18 58 | 59 | - name: Setup dependencies, cache and install 60 | uses: ./.github/actions/install 61 | 62 | - name: Check outdated dependencies 63 | run: npm outdated 64 | 65 | - name: Check unused dependencies 66 | run: npm run lint:dependencies 67 | 68 | - name: Audit dependencies 69 | run: npm audit 70 | 71 | megalinter: 72 | runs-on: ubuntu-latest 73 | steps: 74 | # Git Checkout 75 | - name: Checkout Code 76 | uses: actions/checkout@v4 77 | 78 | # MegaLinter 79 | - name: MegaLinter 80 | # You can override MegaLinter flavor used to have faster performances 81 | # More info at https://megalinter.io/latest/flavors/ 82 | uses: oxsecurity/megalinter/flavors/javascript@v8 83 | env: 84 | # All available variables are described in documentation 85 | # https://megalinter.io/latest/config-file/ 86 | APPLY_FIXES: all 87 | VALIDATE_ALL_CODEBASE: true 88 | # ADD CUSTOM ENV VARIABLES HERE TO OVERRIDE VALUES OF .mega-linter.yml AT THE ROOT OF YOUR REPOSITORY 89 | 90 | - uses: actions/upload-artifact@v4 91 | if: always() 92 | with: 93 | name: megalinter-reports 94 | path: | 95 | megalinter-reports 96 | mega-linter.log 97 | 98 | build: 99 | uses: ./.github/workflows/reusable-build.yml 100 | secrets: inherit 101 | 102 | publish-dev: 103 | needs: [build] 104 | runs-on: ubuntu-latest 105 | if: ${{ github.event.pull_request.merged != 'true' && github.actor != 'dependabot[bot]' }} 106 | outputs: 107 | devChannel: ${{ steps.define_dev_channel.outputs.DEV_CHANNEL }} 108 | permissions: 109 | pull-requests: write 110 | steps: 111 | - name: Checkout sources 112 | uses: actions/checkout@v4 113 | 114 | - name: Setup node 115 | uses: actions/setup-node@v4 116 | with: 117 | node-version: 18 118 | registry-url: 'https://registry.npmjs.org' 119 | 120 | - name: Setup dependencies, cache and install 121 | uses: ./.github/actions/install 122 | 123 | - uses: jwalton/gh-find-current-pr@master 124 | id: pr-number 125 | 126 | - name: Set dev channel value 127 | run: echo "DEV_CHANNEL=dev-${{ steps.pr-number.outputs.pr }}" >> "$GITHUB_ENV" 128 | 129 | - name: Setup github user 130 | run: | 131 | git config --global user.email "${{ env.DEV_CHANNEL }}@github.com" 132 | git config --global user.name "${{ env.DEV_CHANNEL }}" 133 | 134 | - name: NPM Publish dev channel 135 | run: | 136 | CURRENT_VERSION=$(jq -r '.version' package.json) 137 | DEV_TAG="${{ env.DEV_CHANNEL }}.${{ github.run_id }}-${{ github.run_attempt }}" 138 | npm version "${CURRENT_VERSION}-${DEV_TAG}" 139 | npm publish --access public --tag "${{ env.DEV_CHANNEL }}" 140 | env: 141 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 142 | 143 | - name: Comment PR dev channel 144 | uses: thollander/actions-comment-pull-request@v3 145 | with: 146 | message: | 147 | Published under `${{ env.DEV_CHANNEL }}` npm channel. 148 | ```sh 149 | $ sf plugins install apex-mutation-testing@${{ env.DEV_CHANNEL }} 150 | ``` 151 | comment-tag: dev-publish 152 | mode: recreate 153 | 154 | - name: Set dev channel value 155 | id: define_dev_channel 156 | run: echo "DEV_CHANNEL=${{ env.DEV_CHANNEL }}" >> "$GITHUB_OUTPUT" 157 | 158 | e2e-check: 159 | uses: ./.github/workflows/run-e2e-tests.yml 160 | needs: [publish-dev] 161 | with: 162 | channel: ${{ needs.publish-dev.outputs.devChannel }} 163 | secrets: inherit 164 | -------------------------------------------------------------------------------- /.github/workflows/reusable-build.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build Checks 3 | on: 4 | workflow_call: 5 | secrets: 6 | SFDX_AUTH_URL: 7 | required: true 8 | 9 | jobs: 10 | source: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout sources 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup node 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 18 20 | 21 | - uses: google/wireit@setup-github-actions-caching/v2 22 | 23 | - name: Setup dependencies, cache and install 24 | uses: ./.github/actions/install 25 | 26 | - name: Lint plugin 27 | run: npm run lint 28 | 29 | - name: Build plugin 30 | run: npm pack 31 | 32 | - name: Unit test 33 | run: npm run test:unit -- --runInBand 34 | 35 | - name: Install cli 36 | run: npm install -g @salesforce/cli 37 | 38 | - name: Authenticate to NUT test org 39 | run: echo "${{ secrets.SFDX_AUTH_URL }}" | sf org login sfdx-url --alias apex-mutation-testing --set-default --sfdx-url-stdin 40 | 41 | - name: Integration test 42 | run: | 43 | mkdir reports/nut 44 | npm run test:nut 45 | 46 | - uses: actions/upload-artifact@v4 47 | with: 48 | name: coverage-test-report 49 | path: reports/coverage 50 | -------------------------------------------------------------------------------- /.github/workflows/run-e2e-tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: E2E Tests a published version 3 | on: 4 | workflow_call: 5 | inputs: 6 | channel: 7 | type: string 8 | default: latest-rc 9 | 10 | jobs: 11 | e2e-test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Setup node 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: 18 18 | 19 | - name: Set environment variables 20 | run: | 21 | echo "SF_DISABLE_AUTOUPDATE=true" >> "$GITHUB_ENV" 22 | echo "SF_DISABLE_TELEMETRY=true" >> "$GITHUB_ENV" 23 | 24 | - name: Install cli 25 | run: npm install -g @salesforce/cli 26 | 27 | - name: Install new plugin version 28 | run: echo y | sf plugins install apex-mutation-testing@${{ inputs.channel }} 29 | 30 | - name: Test new plugin version installation 31 | run: sf apex mutation test run --help 32 | 33 | - name: Authenticate to E2E test org 34 | run: echo "${{ secrets.SFDX_AUTH_URL }}" | sf org login sfdx-url --alias e2e-test-org --set-default --sfdx-url-stdin 35 | 36 | - name: E2E Tests 37 | run: | 38 | mkdir -p reports/e2e 39 | sf apex mutation test run --target-org e2e-test-org --apex-class Mutation --test-class MutationTest --report-dir reports/e2e --json 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .sfdx 2 | .stryker-tmp 3 | .wireit 4 | *-debug.log 5 | *-error.log 6 | /.nyc_output 7 | /lib 8 | /node_modules 9 | /reports 10 | install-state.gz 11 | megalinter-reports/ 12 | package.tgz 13 | perf-result.txt 14 | apex-mutation-testing-*.tgz 15 | stderr*.txt 16 | stdout*.txt 17 | tsconfig.tsbuildinfo 18 | 19 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx commitlint --edit 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint:staged 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | npm pack 2 | npm run test 3 | npm outdated || true 4 | npm audit || true 5 | npm run lint:dependencies || true 6 | -------------------------------------------------------------------------------- /.jscpd.json: -------------------------------------------------------------------------------- 1 | { 2 | "threshold": 0, 3 | "reporters": ["html", "markdown"], 4 | "ignore": [ 5 | "**/node_modules/**", 6 | "**/.git/**", 7 | "**/*cache*/**", 8 | "**/.github/**", 9 | "**/report/**", 10 | "**/img/**", 11 | "**/__tests__/**", 12 | "**/*.md" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.mega-linter.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Configuration file for MegaLinter 3 | # See all available variables at https://megalinter.io/latest/config-file/ and in linters documentation 4 | 5 | APPLY_FIXES: all # all, none, or list of linter keys 6 | # ENABLE: # If you use ENABLE variable, all other languages/formats/tooling-formats will be disabled by default 7 | # ENABLE_LINTERS: # If you use ENABLE_LINTERS variable, all other linters will be disabled by default 8 | # DISABLE: 9 | # - COPYPASTE # Uncomment to disable checks of excessive copy-pastes 10 | # - SPELL # Uncomment to disable checks of spelling mistakes 11 | DISABLE_LINTERS: 12 | - EDITORCONFIG_EDITORCONFIG_CHECKER 13 | - MARKDOWN_MARKDOWN_LINK_CHECK 14 | - SPELL_MISSPELL 15 | - TYPESCRIPT_PRETTIER 16 | - TYPESCRIPT_STANDARD 17 | SHOW_ELAPSED_TIME: true 18 | FILEIO_REPORTER: false 19 | # DISABLE_ERRORS: true # Uncomment if you want MegaLinter to detect errors but not block CI to pass 20 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": "ts-node/register", 3 | "watch-extensions": "ts", 4 | "node-option": ["experimental-specifier-resolution=node","loader=ts-node/esm"], 5 | "recursive": true, 6 | "reporter": "spec", 7 | "timeout": 120000 8 | } 9 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "check-coverage": true, 3 | "lines": 100, 4 | "statements": 100, 5 | "functions": 100, 6 | "branches": 100 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.1.0](https://github.com/scolladon/apex-mutation-testing/compare/v1.0.0...v1.1.0) (2025-03-02) 4 | 5 | 6 | ### Features 7 | 8 | * generate mutation only for covered lines ([#15](https://github.com/scolladon/apex-mutation-testing/issues/15)) ([e726e57](https://github.com/scolladon/apex-mutation-testing/commit/e726e57571d4cd84db82eae6b150a85d77ecb0f0)) 9 | 10 | ## 1.0.0 (2025-02-27) 11 | 12 | 13 | ### Features 14 | 15 | * implement first mutant generators ([#6](https://github.com/scolladon/apex-mutation-testing/issues/6)) ([3309f64](https://github.com/scolladon/apex-mutation-testing/commit/3309f6415272975534d194f04e8e7b666335b338)) 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to apex-mutation-testing 2 | 3 | We encourage the developer community to contribute to this repository. This guide has instructions to install, build, test and contribute to the framework. 4 | 5 | - [Requirements](#requirements) 6 | - [Installation](#installation) 7 | - [Testing](#testing) 8 | - [Git Workflow](#git-workflow) 9 | 10 | ## Requirements 11 | 12 | - [Node](https://nodejs.org/) >= 18.6.0 13 | - [npm](https://www.npmjs.com/) >= 10.9.0 14 | 15 | ## Installation 16 | 17 | ### 1) Download the repository 18 | ```bash 19 | git clone git@github.com:scolladon/apex-mutation-testing.git 20 | ``` 21 | 22 | ### 2) Install Dependencies 23 | 24 | This will install all the tools needed to contribute 25 | 26 | ```bash 27 | npm install 28 | ``` 29 | 30 | ### 3) Build application 31 | 32 | ```bash 33 | npm pack 34 | ``` 35 | 36 | Rebuild every time you made a change in the source and you need to test locally 37 | 38 | ## Testing 39 | 40 | ### Unit Testing sgd 41 | 42 | When developing, use [jest](https://jestjs.io/en/) unit testing to provide test coverage for new functionality. To run the jest tests use the following command from the root directory: 43 | 44 | ```bash 45 | # just run test 46 | npm run test:unit 47 | ``` 48 | 49 | To execute a particular test, use the following command: 50 | 51 | ```bash 52 | npm run test:unit -- 53 | 54 | ``` 55 | 56 | ### NUT Testing sgd 57 | 58 | When developing, use mocha testing to provide NUT functional test. To run the mocha tests use the following command from the root directory: 59 | 60 | ```bash 61 | # run test 62 | npm run test:nut 63 | ``` 64 | 65 | ### E2E Testing 66 | 67 | WIP 68 | 69 | ## Editor Configurations 70 | 71 | Configure your editor to use our lint and code style rules. 72 | 73 | ### Code formatting 74 | 75 | [Biome](https://biomejs.dev/) Format, lint, and more in a fraction of a second. 76 | 77 | ### Code linting 78 | 79 | [Biome](https://biomejs.dev/) Format, lint, and more in a fraction of a second. 80 | 81 | ### Commit linting 82 | 83 | This repository uses [Commitlint](https://github.com/conventional-changelog/commitlint) to check our commit convention. 84 | Pre-commit git hook using husky and pull request check both the commit convention for each commit in a branch. 85 | 86 | You can use an interactive command line to help you create supported commit message 87 | 88 | ```bash 89 | npm run commit 90 | ``` 91 | ### PR linting 92 | 93 | When a PR is ready for merge we use the PR name to create the squash and merge commit message. 94 | We use the commit convention to auto-generate the content and the type of each release 95 | It needs to follow our commit lint convention and it will be check at the PR level 96 | ## Git Workflow 97 | 98 | The process of submitting a pull request is straightforward and 99 | generally follows the same pattern each time: 100 | 101 | 1. [Fork the repo](#fork-the-repo) 102 | 2. [Create a feature branch](#create-a-feature-branch) 103 | 3. [Make your changes](#make-your-changes) 104 | 4. [Rebase](#rebase) 105 | 5. [Check your submission](#check-your-submission) 106 | 6. [Create a pull request](#create-a-pull-request) 107 | 7. [Update the pull request](#update-the-pull-request) 108 | 109 | ### Fork the repo 110 | 111 | [Fork](https://help.github.com/en/articles/fork-a-repo) the [scolladon/apex-mutation-testing](https://github.com/scolladon/apex-mutation-testing) repo. Clone your fork in your local workspace and [configure](https://help.github.com/en/articles/configuring-a-remote-for-a-fork) your remote repository settings. 112 | 113 | ```bash 114 | git clone git@github.com:/apex-mutation-testing.git 115 | cd apex-mutation-testing 116 | git remote add upstream git@github.com:scolladon/apex-mutation-testing.git 117 | ``` 118 | 119 | ### Create a feature branch 120 | 121 | ```bash 122 | git checkout main 123 | git pull origin main 124 | git checkout -b feature/ 125 | ``` 126 | 127 | ### Make your changes 128 | 129 | Change the files, build, test, lint and commit your code using the following command: 130 | 131 | ```bash 132 | git add 133 | git commit ... 134 | git push origin feature/ 135 | ``` 136 | 137 | Commit your changes using a descriptive commit message 138 | 139 | The above commands will commit the files into your feature branch. You can keep 140 | pushing new changes into the same branch until you are ready to create a pull 141 | request. 142 | 143 | ### Rebase 144 | 145 | Sometimes your feature branch will get stale on the main branch, 146 | and it will must a rebase. Do not use the github UI rebase to keep your commits signed. The following steps can help: 147 | 148 | ```bash 149 | git checkout main 150 | git pull upstream main 151 | git checkout feature/ 152 | git rebase upstream/main 153 | ``` 154 | _note: If no conflicts arise, these commands will apply your changes on top of the main branch. Resolve any conflicts._ 155 | 156 | ### Check your submission 157 | 158 | #### Lint your changes 159 | 160 | ```bash 161 | npm run lint 162 | ``` 163 | 164 | The above command may display lint issues not related to your changes. 165 | The recommended way to avoid lint issues is to [configure your 166 | editor](https://biomejs.dev/guides/integrate-in-vcs/) to warn you in real time as you edit the file. 167 | 168 | the plugin lint all those things : 169 | - typescript files 170 | - folder structure 171 | - plugin parameters 172 | - plugin output 173 | - dependencies 174 | - dead code / configuration 175 | 176 | Fixing all existing lint issues is a tedious task so please pitch in by fixing 177 | the ones related to the files you make changes to! 178 | #### Run tests 179 | 180 | Test your change by running the unit tests and integration tests. Instructions [here](#testing). 181 | 182 | ### Create a pull request 183 | 184 | If you've never created a pull request before, follow [these 185 | instructions](https://help.github.com/articles/creating-a-pull-request/). Pull request samples [here](https://github.com/scolladon/sfdx-git-delta/pulls) 186 | 187 | ### Update the pull request 188 | 189 | ```bash 190 | git fetch origin 191 | git rebase origin/${base_branch} 192 | 193 | # Then force push it 194 | git push origin ${feature_branch} --force-with-lease 195 | ``` 196 | _note: If your pull request needs more changes, keep working on your feature branch as described above._ 197 | 198 | CI validates prettifying, linting and tests 199 | 200 | ### Collaborate on the pull request 201 | 202 | We use [Conventional Comments](https://conventionalcomments.org/) to ensure every comment expresses the intention and is easy to understand. 203 | Pull Request comments are not enforced, it is more a way to help the reviewers and contributors to collaborate on the pull request. 204 | 205 | ## CLI parameters convention 206 | 207 | The plugins uses [sf cli parameters convention](https://github.com/salesforcecli/cli/wiki/Design-Guidelines-Flags) to define parameters for the CLI. 208 | 209 | ## Testing the plugin from a pull request 210 | 211 | To test SGD as a Salesforce CLI plugin from a pending pull request: 212 | 213 | 1. locate the comment with the beta version published in the pull request 214 | 2. install the beta version `sf plugins install apex-mutation-testing@` 215 | 3. test the plugin! 216 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sebastien Colladon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apex Mutation Testing 2 | 3 | [![NPM](https://img.shields.io/npm/v/apex-mutation-testing.svg?label=apex-mutation-testing)](https://www.npmjs.com/package/apex-mutation-testing) [![Downloads/week](https://img.shields.io/npm/dw/apex-mutation-testing.svg)](https://npmjs.org/package/apex-mutation-testing) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/scolladon/apex-mutation-testing/main/LICENSE.md) 4 | ![GitHub Sponsors](https://img.shields.io/github/sponsors/scolladon) 5 | 6 | ## Disclaimer 7 | 8 | This project is in its early stages and requires further development. 9 | It provides a solid foundation for implementing additional features and improvements. 10 | You are welcome to contribute by logging issue, proposing enhancements or pull requests. 11 | 12 | ## TL;DR 13 | 14 | ```sh 15 | sf plugins install apex-mutation-testing 16 | ``` 17 | 18 | ```sh 19 | sf apex mutation test run --class-file MyClass --test-file MyClassTest 20 | ``` 21 | 22 | ## What is it mutation testing ? 23 | 24 | Mutation testing is a software testing technique that evaluates the quality of your test suite by introducing small changes (mutations) to your code and checking if your tests can detect these changes. It helps identify weaknesses in your test coverage by measuring how effectively your tests can catch intentional bugs. cf [wikipedia](https://en.wikipedia.org/wiki/Mutation_testing) 25 | 26 | The apex-mutation-testing plugin implements this technique for Salesforce Apex code by: 27 | 28 | 1. Parsing your Apex class to identify potential mutation points 29 | 2. Generating mutated versions of your code with specific changes 30 | 3. Deploying each mutated version to a Salesforce org 31 | 4. Running your test class against each mutation 32 | 5. Analyzing the results to determine if your tests: 33 | - Detected the mutation (killed the mutant) 34 | - Failed to detect the mutation (created a zombie) 35 | - Caused a test failure unrelated to the mutation 36 | 6. Generating a detailed report showing mutation coverage and test effectiveness 37 | 38 | This process helps you identify areas where your tests may be insufficient and provides insights into improving your test quality. 39 | 40 | cf this [idea](https://ideas.salesforce.com/s/idea/a0B8W00000GdmxmUAB/use-mutation-testing-to-stop-developers-from-cheating-on-apex-tests) for more information about the community appetit 41 | 42 | ## How to use it? 43 | 44 | Fast unit tests are crucial for mutation testing as each detected mutation is deployed and tested individually. The plugin generates numerous mutations, and having quick-running tests allows for: 45 | 1. Efficient execution of the mutation testing process 46 | 2. Faster feedback on test coverage quality 47 | 3. Ability to test more mutations within time constraints 48 | 4. Reduced resource consumption during testing 49 | 5. More iterations and improvements in test quality 50 | 51 | The more the test interacts with the database (dml or soql) the more times the test will take 52 | 53 | ### Test Coverage Requirements 54 | 55 | To maximize the benefits of mutation testing, your test class should have very high code coverage (ideally 100%) **AND** meaningful assert. Here's why: 56 | 57 | 1. **Accurate Metrics**: High coverage ensures the mutation score accurately reflects your test suite's effectiveness. 58 | 59 | 2. **Meaningful Results**: With high coverage, the mutation test results provide actionable insights about your test quality. 60 | 61 | 3. **Mutation Detection**: Mutations detection can be optimized by being scoped to code that is executed by your tests. Uncovered code means not relevant mutations for your tests. 62 | 63 | Before running mutation testing: 64 | - Ensure your test class achieves maximum coverage 65 | - Verify all critical paths are tested 66 | - Include edge case scenarios 67 | - Validate test assertions are comprehensive 68 | 69 | Remember, mutation testing complements but doesn't replace good test coverage. It helps identify weaknesses in your existing tests, but only for the code they already cover. 70 | 71 | 72 | * [`sf apex mutation test run`](#sf-apex-mutation-test-run) 73 | 74 | ## `sf apex mutation test run` 75 | 76 | Evaluate test coverage quality by injecting mutations and measuring test detection rates 77 | 78 | ``` 79 | USAGE 80 | $ sf apex mutation test run -c -t -o [--json] [--flags-dir ] [-r ] [--api-version 81 | ] 82 | 83 | FLAGS 84 | -c, --apex-class= (required) Apex class name to mutate 85 | -o, --target-org= (required) Username or alias of the target org. Not required if the `target-org` 86 | configuration variable is already set. 87 | -r, --report-dir= [default: mutations] Path to the directory where mutation test reports will be generated 88 | -t, --test-class= (required) Apex test class name to validate mutations 89 | --api-version= Override the api version used for api requests made by this command 90 | 91 | GLOBAL FLAGS 92 | --flags-dir= Import flag values from a directory. 93 | --json Format output as json. 94 | 95 | DESCRIPTION 96 | Evaluate test coverage quality by injecting mutations and measuring test detection rates 97 | 98 | The Apex Mutation Testing plugin helps evaluate the effectiveness of your Apex test classes by introducing mutations 99 | into your code and checking if your tests can detect these changes: 100 | 101 | The plugin provides insights into how trustworthy your test suite is by measuring its ability to catch intentional 102 | code changes. 103 | 104 | EXAMPLES 105 | Run mutation testing on a class with its test file: 106 | 107 | $ sf apex mutation test run --class-file MyClass --test-file MyClassTest 108 | ``` 109 | 110 | 111 | ## Backlog 112 | 113 | - **Expand Mutation Types**: Add more mutation operators to test different code patterns 114 | - **Smart Mutation Detection**: Implement logic to identify relevant mutations for specific code contexts 115 | - **Coverage Analysis**: Detect untested code paths that mutations won't affect 116 | - **Performance Optimization**: Add CPU time monitoring to fail fast on non ending mutation 117 | - **Better Configurability**: Pass threashold and use more information from test class 118 | - **Additional Features**: Explore other mutation testing enhancements and quality metrics 119 | 120 | ## Changelog 121 | 122 | [changelog.md](CHANGELOG.md) is available for consultation. 123 | 124 | ## Versioning 125 | 126 | Versioning follows [SemVer](http://semver.org/) specification. 127 | 128 | ## Authors 129 | 130 | - **Sebastien Colladon** - Developer - [scolladon](https://github.com/scolladon) 131 | 132 | Special thanks to **Sara Sali** for her [presentation at Dreamforce](https://www.youtube.com/watch?v=8PjzrTaNNns) about apex mutation testing 133 | This repository is basically a port of her idea / repo to a sf plugin. 134 | 135 | ## Contributing 136 | 137 | Contributions are what make the trailblazer community such an amazing place. I regard this component as a way to inspire and learn from others. Any contributions you make are **appreciated**. 138 | 139 | See [contributing.md](CONTRIBUTING.md) for sgd contribution principles. 140 | 141 | ## License 142 | 143 | This project license is MIT - see the [LICENSE.md](LICENSE.md) file for details 144 | -------------------------------------------------------------------------------- /bin/dev.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node --loader ts-node/esm --no-warnings=ExperimentalWarning "%~dp0\dev" %* 4 | -------------------------------------------------------------------------------- /bin/dev.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S NODE_OPTIONS="--no-warnings=ExperimentalWarning" npx ts-node --project tsconfig.json --esm 2 | async function main() { 3 | const { execute } = await import('@oclif/core') 4 | await execute({ development: true, dir: import.meta.url }) 5 | } 6 | 7 | await main() 8 | -------------------------------------------------------------------------------- /bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /bin/run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | async function main() { 3 | const { execute } = await import('@oclif/core') 4 | await execute({ dir: import.meta.url }) 5 | } 6 | 7 | await main() 8 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", 3 | "formatter": { 4 | "enabled": true, 5 | "formatWithErrors": false, 6 | "indentStyle": "space", 7 | "indentWidth": 2, 8 | "lineEnding": "lf", 9 | "lineWidth": 80, 10 | "attributePosition": "auto", 11 | "ignore": [ 12 | "**/.next", 13 | "**/node_modules", 14 | "**/output", 15 | "**/reports", 16 | "**/.github", 17 | "**/*.json", 18 | "**/*.md" 19 | ] 20 | }, 21 | "organizeImports": { "enabled": true }, 22 | "linter": { 23 | "enabled": true, 24 | "rules": { 25 | "recommended": false, 26 | "complexity": { 27 | "noExtraBooleanCast": "error", 28 | "noMultipleSpacesInRegularExpressionLiterals": "error", 29 | "noUselessCatch": "error", 30 | "noUselessThisAlias": "error", 31 | "noUselessTypeConstraint": "error", 32 | "noWith": "error", 33 | "useArrowFunction": "off" 34 | }, 35 | "correctness": { 36 | "noConstAssign": "error", 37 | "noConstantCondition": "error", 38 | "noEmptyCharacterClassInRegex": "error", 39 | "noEmptyPattern": "error", 40 | "noGlobalObjectCalls": "error", 41 | "noInnerDeclarations": "error", 42 | "noInvalidConstructorSuper": "error", 43 | "noNewSymbol": "error", 44 | "noNonoctalDecimalEscape": "error", 45 | "noPrecisionLoss": "error", 46 | "noSelfAssign": "error", 47 | "noSetterReturn": "error", 48 | "noSwitchDeclarations": "error", 49 | "noUndeclaredVariables": "error", 50 | "noUnreachable": "error", 51 | "noUnreachableSuper": "error", 52 | "noUnsafeFinally": "error", 53 | "noUnsafeOptionalChaining": "error", 54 | "noUnusedLabels": "error", 55 | "noUnusedVariables": "error", 56 | "useArrayLiterals": "off", 57 | "useIsNan": "error", 58 | "useValidForDirection": "error", 59 | "useYield": "error" 60 | }, 61 | "style": { 62 | "noNamespace": "error", 63 | "noNonNullAssertion": "off", 64 | "useAsConstAssertion": "error", 65 | "useBlockStatements": "off" 66 | }, 67 | "suspicious": { 68 | "noAssignInExpressions": "error", 69 | "noAsyncPromiseExecutor": "error", 70 | "noCatchAssign": "error", 71 | "noClassAssign": "error", 72 | "noCompareNegZero": "error", 73 | "noConsoleLog": "error", 74 | "noControlCharactersInRegex": "error", 75 | "noDebugger": "error", 76 | "noDuplicateCase": "error", 77 | "noDuplicateClassMembers": "error", 78 | "noDuplicateObjectKeys": "error", 79 | "noDuplicateParameters": "error", 80 | "noEmptyBlockStatements": "error", 81 | "noExplicitAny": "error", 82 | "noExtraNonNullAssertion": "error", 83 | "noFallthroughSwitchClause": "error", 84 | "noFunctionAssign": "error", 85 | "noGlobalAssign": "error", 86 | "noImportAssign": "error", 87 | "noMisleadingCharacterClass": "error", 88 | "noMisleadingInstantiator": "error", 89 | "noPrototypeBuiltins": "error", 90 | "noRedeclare": "error", 91 | "noShadowRestrictedNames": "error", 92 | "noUnsafeDeclarationMerging": "error", 93 | "noUnsafeNegation": "error", 94 | "useGetterReturn": "error", 95 | "useNamespaceKeyword": "error", 96 | "useValidTypeof": "error" 97 | } 98 | }, 99 | "ignore": [ 100 | "lib/**/*", 101 | "**/node_modules", 102 | "**/.next", 103 | "**/output", 104 | "**/reports", 105 | "**/.github" 106 | ] 107 | }, 108 | "javascript": { 109 | "formatter": { 110 | "jsxQuoteStyle": "double", 111 | "quoteProperties": "asNeeded", 112 | "trailingCommas": "es5", 113 | "semicolons": "asNeeded", 114 | "arrowParentheses": "asNeeded", 115 | "bracketSpacing": true, 116 | "bracketSameLine": false, 117 | "quoteStyle": "single", 118 | "attributePosition": "auto" 119 | }, 120 | "globals": ["Atomics", "SharedArrayBuffer"] 121 | }, 122 | "overrides": [ 123 | { 124 | "include": ["*.ts", "*.tsx", "*.mts", "*.cts"], 125 | "linter": { 126 | "rules": { 127 | "correctness": { 128 | "noConstAssign": "off", 129 | "noGlobalObjectCalls": "off", 130 | "noInvalidConstructorSuper": "off", 131 | "noInvalidNewBuiltin": "off", 132 | "noNewSymbol": "off", 133 | "noSetterReturn": "off", 134 | "noUndeclaredVariables": "off", 135 | "noUnreachable": "off", 136 | "noUnreachableSuper": "off" 137 | }, 138 | "style": { 139 | "noArguments": "error", 140 | "noVar": "error", 141 | "useConst": "error" 142 | }, 143 | "suspicious": { 144 | "noDuplicateClassMembers": "off", 145 | "noDuplicateObjectKeys": "off", 146 | "noDuplicateParameters": "off", 147 | "noFunctionAssign": "off", 148 | "noImportAssign": "off", 149 | "noRedeclare": "off", 150 | "noUnsafeNegation": "off", 151 | "useGetterReturn": "off" 152 | } 153 | } 154 | } 155 | } 156 | ] 157 | } 158 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] } 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | export default { 5 | // All imported modules in your tests should be mocked automatically 6 | automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/mg/m4_x4ws179vds4c1brqh103mjyhjbm/T/jest_9yo1v8", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: null, 25 | 26 | // The directory where Jest should output its coverage files 27 | coverageDirectory: 'reports/coverage', 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | coveragePathIgnorePatterns: ['/node_modules/', '/test/utils/', '/reports/'], 31 | 32 | // A list of reporter names that Jest uses when writing coverage reports 33 | coverageReporters: [ 34 | // "json", 35 | // "text", 36 | 'lcov', 37 | // "clover" 38 | ], 39 | 40 | // An object that configures minimum threshold enforcement for coverage results 41 | coverageThreshold: { 42 | global: { 43 | branches: 90, 44 | functions: 90, 45 | lines: 95, 46 | statements: 95, 47 | }, 48 | }, 49 | 50 | // A path to a custom dependency extractor 51 | // dependencyExtractor: null, 52 | 53 | // Make calling deprecated APIs throw helpful error messages 54 | // errorOnDeprecated: false, 55 | 56 | // Force coverage collection from ignored files using an array of glob patterns 57 | // forceCoverageMatch: [], 58 | 59 | // A path to a module which exports an async function that is triggered once before all test suites 60 | // globalSetup: null, 61 | 62 | // A path to a module which exports an async function that is triggered once after all test suites 63 | // globalTeardown: null, 64 | 65 | // A set of global variables that need to be available in all test environments 66 | // globals: {}, 67 | 68 | // An array of directory names to be searched recursively up from the requiring module's location 69 | // moduleDirectories: [ 70 | // "node_modules" 71 | // ], 72 | 73 | // A map from regular expressions to paths to transformers 74 | transform: { 75 | '\\.ts$': ['ts-jest', { tsconfig: './tsconfig.json' }], 76 | }, 77 | extensionsToTreatAsEsm: ['.ts'], 78 | // A map from regular expressions to module names that allow to stub out resources with a single module 79 | moduleNameMapper: { 80 | '(.+)\\.js': '$1', 81 | }, 82 | 83 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 84 | // modulePathIgnorePatterns: [], 85 | 86 | // Activates notifications for test results 87 | // notify: false, 88 | 89 | // An enum that specifies notification mode. Requires { notify: true } 90 | // notifyMode: "failure-change", 91 | 92 | // A preset that is used as a base for Jest's configuration 93 | //preset: 'ts-jest', 94 | 95 | // Run tests from one or more projects 96 | // projects: null, 97 | 98 | // Use this configuration option to add custom reporters to Jest 99 | // reporters: undefined, 100 | 101 | // Automatically reset mock state between every test 102 | // resetMocks: false, 103 | 104 | // Reset the module registry before running each individual test 105 | // resetModules: false, 106 | 107 | // A path to a custom resolver 108 | // resolver: null, 109 | 110 | // Automatically restore mock state between every test 111 | // restoreMocks: false, 112 | 113 | // The root directory that Jest should scan for tests and modules within 114 | // rootDir: null, 115 | 116 | // A list of paths to directories that Jest should use to search for files in 117 | // roots: [ 118 | // "" 119 | // ], 120 | 121 | // Allows you to use a custom runner instead of Jest's default test runner 122 | // runner: "jest-runner", 123 | 124 | // The paths to modules that run some code to configure or set up the testing environment before each test 125 | //setupFiles: ['./test/utils/globalTestHelper.ts'], 126 | 127 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 128 | // setupFilesAfterEnv: [], 129 | 130 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 131 | // snapshotSerializers: [], 132 | 133 | // The test environment that will be used for testing 134 | testEnvironment: 'node', 135 | 136 | // Options that will be passed to the testEnvironment 137 | // testEnvironmentOptions: {}, 138 | 139 | // Adds a location field to test results 140 | // testLocationInResults: false, 141 | 142 | // The glob patterns Jest uses to detect test files 143 | testMatch: ['**/test/**/*.test.ts'], 144 | 145 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 146 | testPathIgnorePatterns: [ 147 | 'src', 148 | '/node_modules/', 149 | '/test/utils/', 150 | '/reports/', 151 | ], 152 | 153 | // The regexp pattern or array of patterns that Jest uses to detect test files 154 | // testRegex: [], 155 | 156 | // This option allows the use of a custom results processor 157 | // testResultsProcessor: null, 158 | 159 | // This option allows use of a custom test runner 160 | // testRunner: "jasmine2", 161 | 162 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 163 | // testURL: "http://localhost", 164 | 165 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 166 | // timers: "real", 167 | 168 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 169 | // transformIgnorePatterns: [ 170 | // "/node_modules/" 171 | // ], 172 | 173 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 174 | // unmockedModulePathPatterns: undefined, 175 | 176 | // Indicates whether each individual test should be reported during the run 177 | // verbose: null, 178 | 179 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 180 | // watchPathIgnorePatterns: [], 181 | 182 | // Whether to use watchman for file crawling 183 | // watchman: true, 184 | } 185 | -------------------------------------------------------------------------------- /knip.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | packageManager: 'npm', 3 | entry: [ 4 | 'src/commands/apex/mutation/test/run.ts', 5 | 'bin/dev.js', 6 | 'bin/run.js', 7 | '**/*.{nut,test}.ts', 8 | '.github/**/*.yml', 9 | ], 10 | project: ['**/*.{ts,js,json,yml}'], 11 | ignoreDependencies: [ 12 | '@commitlint/config-conventional', 13 | '@stryker-mutator/core', 14 | 'ts-node', 15 | ], 16 | ignoreBinaries: ['commitlint', 'npm-check-updates'], 17 | } 18 | -------------------------------------------------------------------------------- /lychee.toml: -------------------------------------------------------------------------------- 1 | exclude_mail = true 2 | exclude_path = ["CHANGELOG.md", "package-lock.json"] 3 | -------------------------------------------------------------------------------- /messages/apex.mutation.test.run.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | Evaluate test coverage quality by injecting mutations and measuring test detection rates 4 | 5 | # description 6 | 7 | The Apex Mutation Testing plugin helps evaluate the effectiveness of your Apex test classes by introducing mutations into your code and checking if your tests can detect these changes: 8 | 9 | The plugin provides insights into how trustworthy your test suite is by measuring its ability to catch intentional code changes. 10 | 11 | # flags.apex-class.summary 12 | 13 | Apex class name to mutate 14 | 15 | # flags.test-class.summary 16 | 17 | Apex test class name to validate mutations 18 | 19 | # flags.report-dir.summary 20 | 21 | Path to the directory where mutation test reports will be generated 22 | 23 | # examples 24 | 25 | - Run mutation testing on a class with its test file: 26 | 27 | <%= config.bin %> <%= command.id %> --class-file MyClass --test-file MyClassTest 28 | 29 | # info.reportGenerated 30 | 31 | Report has been generated at this location: %s 32 | 33 | # info.CommandIsRunning 34 | 35 | Running mutation testing for "%s" with "%s" test class 36 | 37 | # info.CommandSuccess 38 | 39 | Mutation score: %s% 40 | 41 | # info.CommandFailure 42 | 43 | Failure 44 | 45 | # info.EncourageSponsorship 46 | 47 | 💡 Enjoying apex-mutation-testing? 48 | Your contribution helps us provide fast support 🚀 and high quality features 🔥 49 | Become a sponsor: https://github.com/sponsors/scolladon 💙 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "apex-mutation-testing", 3 | "description": "Apex mutation testing plugin", 4 | "version": "1.1.0", 5 | "dependencies": { 6 | "@oclif/core": "^4.2.8", 7 | "@salesforce/apex-node": "^8.1.19", 8 | "@salesforce/core": "^8.8.3", 9 | "@salesforce/sf-plugins-core": "^12.2.0", 10 | "@stryker-mutator/core": "^8.7.1", 11 | "antlr4ts": "^0.5.0-alpha.4", 12 | "apex-parser": "^2.17.0" 13 | }, 14 | "devDependencies": { 15 | "@biomejs/biome": "1.9.4", 16 | "@commitlint/config-conventional": "^19.7.1", 17 | "@oclif/plugin-help": "^6.2.26", 18 | "@salesforce/cli-plugins-testkit": "^5.3.39", 19 | "@salesforce/dev-config": "^4.3.1", 20 | "@types/chai": "^5.0.1", 21 | "@types/jest": "^29.5.14", 22 | "chai": "^5.2.0", 23 | "husky": "^9.1.7", 24 | "jest": "^29.7.0", 25 | "knip": "^5.45.0", 26 | "mocha": "^11.1.0", 27 | "nyc": "^17.1.0", 28 | "oclif": "^4.17.32", 29 | "shx": "^0.3.4", 30 | "ts-jest": "^29.2.6", 31 | "ts-node": "^10.9.2", 32 | "tslib": "^2.8.1", 33 | "typescript": "^5.7.3", 34 | "wireit": "^0.14.11" 35 | }, 36 | "engines": { 37 | "node": ">=18.6.0" 38 | }, 39 | "files": [ 40 | "/lib", 41 | "/messages", 42 | "/npm-shrinkwrap.json", 43 | "/oclif.manifest.json", 44 | "/oclif.lock" 45 | ], 46 | "keywords": [ 47 | "sf", 48 | "sf-plugin", 49 | "apex", 50 | "test", 51 | "mutation" 52 | ], 53 | "license": "MIT", 54 | "oclif": { 55 | "commands": "./lib/commands", 56 | "bin": "sf", 57 | "topicSeparator": " ", 58 | "flexibleTaxonomy": true, 59 | "devPlugins": [ 60 | "@oclif/plugin-help" 61 | ] 62 | }, 63 | "scripts": { 64 | "build": "wireit", 65 | "clean:package-manager": "wireit", 66 | "clean": "wireit", 67 | "compile": "wireit", 68 | "dependencies:upgrade": "npx npm-check-updates -u ; npm install ; npm audit fix", 69 | "lint": "wireit", 70 | "lint:dependencies": "wireit", 71 | "lint:fix": "wireit", 72 | "lint:staged": "wireit", 73 | "postpack": "shx rm -f oclif.manifest.json", 74 | "prepack": "wireit", 75 | "prepare": "husky", 76 | "prepublishOnly": "npm shrinkwrap", 77 | "test:build": "wireit", 78 | "test:nut": "wireit", 79 | "test:unit": "wireit", 80 | "test": "wireit" 81 | }, 82 | "publishConfig": { 83 | "access": "public" 84 | }, 85 | "wireit": { 86 | "build": { 87 | "dependencies": [ 88 | "compile", 89 | "lint" 90 | ] 91 | }, 92 | "clean": { 93 | "command": "shx rm -rf 'reports/*' .nyc_output oclif.manifest.json package.tgz 'sfdx-git-delta-*.tgz' 'stderr*.txt' 'stdout*.txt' '.stryker-tmp/*' perf-result.txt", 94 | "files": [ 95 | "lib", 96 | "reports/*", 97 | ".nyc_output", 98 | "oclif.manifest.json", 99 | "package.tgz", 100 | "sfdx-git-delta-v*.tgz", 101 | "stderr*.txt", 102 | "stdout*.txt", 103 | ".stryker-tmp/*", 104 | "perf-result.txt" 105 | ], 106 | "dependencies": [ 107 | "clean:build" 108 | ] 109 | }, 110 | "clean:build": { 111 | "command": "shx rm -rf lib", 112 | "files": [ 113 | "lib/*" 114 | ] 115 | }, 116 | "clean:package-manager": { 117 | "command": "shx rm -rf node_modules tsconfig.tsbuildinfo .wireit", 118 | "files": [ 119 | "node_modules/*" 120 | ] 121 | }, 122 | "compile": { 123 | "command": "tsc -p . --pretty --incremental", 124 | "files": [ 125 | "src/**/*.ts", 126 | "**/tsconfig.json", 127 | "messages/**" 128 | ], 129 | "output": [ 130 | "lib/**", 131 | "*.tsbuildinfo" 132 | ], 133 | "clean": "if-file-deleted" 134 | }, 135 | "lint": { 136 | "command": "npx @biomejs/biome check --error-on-warnings --no-errors-on-unmatched src test", 137 | "files": [ 138 | "src/**", 139 | "test/**/*.ts", 140 | "messages/**", 141 | "**/biome.json", 142 | "**/tsconfig.json" 143 | ], 144 | "output": [] 145 | }, 146 | "lint:dependencies": { 147 | "command": "knip", 148 | "files": [ 149 | "src/**/*.ts", 150 | "test/**/*.ts", 151 | "messages/**", 152 | "**/tsconfig.json", 153 | "knip.config.ts" 154 | ], 155 | "output": [], 156 | "dependencies": [ 157 | "lint" 158 | ] 159 | }, 160 | "lint:fix": { 161 | "command": "npx @biomejs/biome check --error-on-warnings --no-errors-on-unmatched src test --fix --unsafe ", 162 | "files": [ 163 | "src/**", 164 | "test/**/*.ts", 165 | "messages/**", 166 | "**/biome.json", 167 | "**/tsconfig.json" 168 | ], 169 | "output": [] 170 | }, 171 | "lint:staged": { 172 | "command": "npx @biomejs/biome check --error-on-warnings --no-errors-on-unmatched --staged src test", 173 | "files": [ 174 | "src/**", 175 | "test/**/*.ts", 176 | "messages/**", 177 | "**/biome.json", 178 | "**/tsconfig.json" 179 | ], 180 | "output": [] 181 | }, 182 | "prepack": { 183 | "command": "oclif manifest && oclif readme", 184 | "files": [ 185 | "src/commands/apex/mutation/test/run.ts", 186 | "messages/apex.mutation.test.run.md", 187 | "README.md" 188 | ], 189 | "dependencies": [ 190 | "build" 191 | ], 192 | "output": [ 193 | "README.md" 194 | ], 195 | "clean": false 196 | }, 197 | "test": { 198 | "dependencies": [ 199 | "build", 200 | "lint", 201 | "test:unit", 202 | "test:nut" 203 | ] 204 | }, 205 | "test:build": { 206 | "command": "npm install && npm pack && npm run test", 207 | "dependencies": [ 208 | "clean", 209 | "clean:package-manager" 210 | ] 211 | }, 212 | "test:nut": { 213 | "command": "nyc mocha -r ts-node/register **/*.nut.ts", 214 | "files": [ 215 | "src/**/*.ts", 216 | "test/**/*.ts", 217 | "messages/**", 218 | "**/tsconfig.json" 219 | ], 220 | "output": [ 221 | ".nyc_output/**" 222 | ], 223 | "dependencies": [ 224 | "lint", 225 | "build" 226 | ] 227 | }, 228 | "test:unit": { 229 | "command": "jest --coverage", 230 | "files": [ 231 | "src/**/*.ts", 232 | "__tests__/**/*.ts", 233 | "messages/**", 234 | "**/tsconfig.json" 235 | ], 236 | "output": [ 237 | "reports/coverage/**" 238 | ], 239 | "dependencies": [ 240 | "lint" 241 | ] 242 | } 243 | }, 244 | "type": "module", 245 | "author": "Sébastien Colladon (colladonsebastien@gmail.com)" 246 | } 247 | -------------------------------------------------------------------------------- /src/adapter/apexClassRepository.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from '@salesforce/core' 2 | import { ApexClass } from '../type/ApexClass.js' 3 | export class ApexClassRepository { 4 | constructor(protected readonly connection: Connection) {} 5 | 6 | public async read(name: string) { 7 | return ( 8 | await this.connection.tooling 9 | .sobject('ApexClass') 10 | .find({ Name: name }) 11 | .execute() 12 | )[0] 13 | } 14 | 15 | public async update(apexClass: ApexClass) { 16 | const container = await this.connection.tooling 17 | .sobject('MetadataContainer') 18 | .create({ 19 | Name: `MutationTest_${Date.now()}`, 20 | }) 21 | 22 | // Create ApexClassMember for the mutated version 23 | await this.connection.tooling.sobject('ApexClassMember').create({ 24 | MetadataContainerId: container.id, 25 | ContentEntityId: apexClass.Id, 26 | Body: apexClass.Body, 27 | }) 28 | 29 | // Create ContainerAsyncRequest to deploy 30 | return await this.connection.tooling 31 | .sobject('ContainerAsyncRequest') 32 | .create({ 33 | IsCheckOnly: false, 34 | MetadataContainerId: container.id, 35 | IsRunTests: true, 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/adapter/apexTestRunner.ts: -------------------------------------------------------------------------------- 1 | import { TestLevel, TestResult, TestService } from '@salesforce/apex-node' 2 | import { Connection } from '@salesforce/core' 3 | 4 | export class ApexTestRunner { 5 | protected readonly testService: TestService 6 | constructor(connection: Connection) { 7 | this.testService = new TestService(connection) 8 | } 9 | 10 | public async getCoveredLines(testClassName: string) { 11 | const testResult = await this.runTestAsynchronous(testClassName, false) 12 | return new Set( 13 | testResult.codecoverage?.flatMap(coverage => coverage.coveredLines) 14 | ) 15 | } 16 | 17 | public async run(testClassName: string) { 18 | return await this.runTestAsynchronous(testClassName) 19 | } 20 | 21 | private async runTestAsynchronous( 22 | testClassName: string, 23 | skipCodeCoverage: boolean = true 24 | ) { 25 | return (await this.testService.runTestAsynchronous( 26 | { 27 | tests: [{ className: testClassName }], 28 | testLevel: TestLevel.RunSpecifiedTests, 29 | skipCodeCoverage, 30 | maxFailedTests: 0, 31 | }, 32 | !skipCodeCoverage 33 | )) as TestResult 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/commands/apex/mutation/test/run.ts: -------------------------------------------------------------------------------- 1 | import { Messages } from '@salesforce/core' 2 | import { Flags, SfCommand } from '@salesforce/sf-plugins-core' 3 | import { ApexMutationHTMLReporter } from '../../../../reporter/HTMLReporter.js' 4 | import { ApexClassValidator } from '../../../../service/apexClassValidator.js' 5 | import { MutationTestingService } from '../../../../service/mutationTestingService.js' 6 | import { ApexMutationParameter } from '../../../../type/ApexMutationParameter.js' 7 | 8 | Messages.importMessagesDirectoryFromMetaUrl(import.meta.url) 9 | const messages = Messages.loadMessages( 10 | 'apex-mutation-testing', 11 | 'apex.mutation.test.run' 12 | ) 13 | 14 | export type ApexMutationTestResult = { 15 | score: number 16 | } 17 | 18 | export default class ApexMutationTest extends SfCommand { 19 | public static override readonly summary = messages.getMessage('summary') 20 | public static override readonly description = 21 | messages.getMessage('description') 22 | public static override readonly examples = messages.getMessages('examples') 23 | 24 | public static override readonly flags = { 25 | 'apex-class': Flags.string({ 26 | char: 'c', 27 | summary: messages.getMessage('flags.apex-class.summary'), 28 | required: true, 29 | }), 30 | 'test-class': Flags.string({ 31 | char: 't', 32 | summary: messages.getMessage('flags.test-class.summary'), 33 | required: true, 34 | }), 35 | 'report-dir': Flags.directory({ 36 | char: 'r', 37 | summary: messages.getMessage('flags.report-dir.summary'), 38 | exists: true, 39 | default: 'mutations', 40 | }), 41 | 'target-org': Flags.requiredOrg(), 42 | 'api-version': Flags.orgApiVersion(), 43 | } 44 | 45 | public async run(): Promise { 46 | // parse the provided flags 47 | const { flags } = await this.parse(ApexMutationTest) 48 | const connection = flags['target-org'].getConnection(flags['api-version']) 49 | 50 | const parameters: ApexMutationParameter = { 51 | apexClassName: flags['apex-class'], 52 | apexTestClassName: flags['test-class'], 53 | reportDir: flags['report-dir'], 54 | } 55 | 56 | this.log( 57 | messages.getMessage('info.CommandIsRunning', [ 58 | parameters.apexClassName, 59 | parameters.apexTestClassName, 60 | ]) 61 | ) 62 | 63 | const apexClassValidator = new ApexClassValidator(connection) 64 | await apexClassValidator.validate(parameters) 65 | 66 | const mutationTestingService = new MutationTestingService( 67 | this.progress, 68 | this.spinner, 69 | connection, 70 | parameters 71 | ) 72 | const mutationResult = await mutationTestingService.process() 73 | 74 | const htmlReporter = new ApexMutationHTMLReporter() 75 | await htmlReporter.generateReport(mutationResult, parameters.reportDir) 76 | this.log( 77 | messages.getMessage('info.reportGenerated', [parameters.reportDir]) 78 | ) 79 | 80 | const score = mutationTestingService.calculateScore(mutationResult) 81 | 82 | this.log(messages.getMessage('info.CommandSuccess', [score])) 83 | 84 | this.info(messages.getMessage('info.EncourageSponsorship')) 85 | return { 86 | score, 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/mutator/baseListener.ts: -------------------------------------------------------------------------------- 1 | import { ApexParserListener } from 'apex-parser' 2 | import { ApexMutation } from '../type/ApexMutation.js' 3 | 4 | // @ts-ignore: Base type with just a common _mutations property 5 | export class BaseListener implements ApexParserListener { 6 | _mutations: ApexMutation[] = [] 7 | } 8 | -------------------------------------------------------------------------------- /src/mutator/boundaryConditionMutator.ts: -------------------------------------------------------------------------------- 1 | import { BaseListener } from './baseListener.js' 2 | 3 | import { ParserRuleContext } from 'antlr4ts' 4 | import { TerminalNode } from 'antlr4ts/tree/index.js' 5 | 6 | export class BoundaryConditionMutator extends BaseListener { 7 | private REPLACEMENT_MAP: Record = { 8 | '!=': '==', 9 | '==': '!=', 10 | '<': '<=', 11 | '<=': '<', 12 | '>': '>=', 13 | '>=': '>', 14 | '===': '!==', 15 | '!==': '===', 16 | } 17 | 18 | // Target rule 19 | // expression: expression ('<=' | '>=' | '>' | '<') expression 20 | enterParExpression(ctx: ParserRuleContext): void { 21 | if (ctx.childCount === 3) { 22 | const symbol = ctx.getChild(1).getChild(1) 23 | if (symbol instanceof TerminalNode) { 24 | const symbolText = symbol.text 25 | const replacement = this.REPLACEMENT_MAP[symbolText] 26 | if (replacement) { 27 | this._mutations.push({ 28 | mutationName: this.constructor.name, 29 | token: symbol, 30 | replacement, 31 | }) 32 | } 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/mutator/incrementMutator.ts: -------------------------------------------------------------------------------- 1 | import { BaseListener } from './baseListener.js' 2 | 3 | import { ParserRuleContext } from 'antlr4ts' 4 | import { TerminalNode } from 'antlr4ts/tree/index.js' 5 | 6 | export class IncrementMutator extends BaseListener { 7 | private REPLACEMENT_MAP: Record = { 8 | '++': '--', 9 | '--': '++', 10 | } 11 | 12 | // Target rule 13 | // expression : 14 | // | expression ('++' | '--') 15 | // | ('+' | '-' | '++' | '--') expression 16 | enterPostOpExpression(ctx: ParserRuleContext): void { 17 | this.processOperation(ctx) 18 | } 19 | 20 | enterPreOpExpression(ctx: ParserRuleContext): void { 21 | this.processOperation(ctx) 22 | } 23 | 24 | private processOperation(ctx: ParserRuleContext) { 25 | if (ctx.childCount === 2) { 26 | let symbol: TerminalNode | null = null 27 | if (ctx.getChild(0) instanceof TerminalNode) { 28 | symbol = ctx.getChild(0) as TerminalNode 29 | } else if (ctx.getChild(1) instanceof TerminalNode) { 30 | symbol = ctx.getChild(1) as TerminalNode 31 | } 32 | 33 | if (symbol !== null && symbol.text in this.REPLACEMENT_MAP) { 34 | this._mutations.push({ 35 | mutationName: this.constructor.name, 36 | token: symbol, 37 | replacement: this.REPLACEMENT_MAP[symbol.text], 38 | }) 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/mutator/mutationListener.ts: -------------------------------------------------------------------------------- 1 | import { ParserRuleContext } from 'antlr4ts' 2 | import { ApexMutation } from '../type/ApexMutation.js' 3 | import { BaseListener } from './baseListener.js' 4 | 5 | import { ApexParserListener } from 'apex-parser' 6 | 7 | // @ts-ignore: Just a proxy doing accumulation of mutations 8 | export class MutationListener implements ApexParserListener { 9 | private listeners: BaseListener[] 10 | _mutations: ApexMutation[] = [] 11 | 12 | public getMutations() { 13 | return this._mutations 14 | } 15 | 16 | constructor( 17 | listeners: BaseListener[], 18 | protected readonly coveredLines: Set 19 | ) { 20 | this.listeners = listeners 21 | // Share mutations array across all listeners 22 | this.listeners 23 | .filter(listener => '_mutations' in listener) 24 | .forEach(listener => { 25 | ;(listener as BaseListener)._mutations = this._mutations 26 | }) 27 | 28 | // Create a proxy that automatically forwards all method calls to listeners 29 | return new Proxy(this, { 30 | get: (target, prop) => { 31 | if (prop in target) { 32 | return target[prop] 33 | } 34 | 35 | // Return a function that calls the method on all listeners that have it 36 | return (...args: unknown[]) => { 37 | if (Array.isArray(args) && args.length > 0) { 38 | const ctx = args[0] as ParserRuleContext 39 | if (this.coveredLines.has(ctx?.start?.line)) { 40 | this.listeners.forEach(listener => { 41 | if (prop in listener && typeof listener[prop] === 'function') { 42 | ;(listener[prop] as Function).apply(listener, args) 43 | } 44 | }) 45 | } 46 | } 47 | } 48 | }, 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/reporter/HTMLReporter.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from 'node:fs/promises' 2 | import * as path from 'path' 3 | import { ApexMutationTestResult } from '../type/ApexMutationTestResult.js' 4 | 5 | export class ApexMutationHTMLReporter { 6 | async generateReport( 7 | apexMutationTestResult: ApexMutationTestResult, 8 | outputDir: string = 'reports' 9 | ): Promise { 10 | const reportData = this.transformApexResults(apexMutationTestResult) 11 | // Generate and write the HTML file with the report data embedded 12 | const htmlContent = createReportHtml(reportData) 13 | await writeFile(path.join(outputDir, 'index.html'), htmlContent) 14 | } 15 | 16 | private transformApexResults(apexMutationTestResult: ApexMutationTestResult) { 17 | const mutationTestResult = { 18 | schemaVersion: '2.0.0', 19 | config: {}, // You can add your configuration here 20 | thresholds: { 21 | high: 80, 22 | low: 60, 23 | }, 24 | files: {}, 25 | } 26 | 27 | const fileResult = { 28 | language: 'java', 29 | source: apexMutationTestResult.sourceFileContent, 30 | mutants: apexMutationTestResult.mutants.map(mutant => ({ 31 | id: mutant.id, 32 | mutatorName: mutant.mutatorName, 33 | replacement: mutant.replacement, 34 | status: mutant.status, 35 | static: false, 36 | coveredBy: ['0'], 37 | killedBy: mutant.status === 'Killed' ? ['0'] : undefined, 38 | testsCompleted: 1, 39 | location: { 40 | start: { 41 | line: mutant.location.start.line, 42 | column: mutant.location.start.column, 43 | }, 44 | end: { 45 | line: mutant.location.end.line, 46 | column: mutant.location.end.column, 47 | }, 48 | }, 49 | })), 50 | } 51 | 52 | mutationTestResult.files[`${apexMutationTestResult.sourceFile}.java`] = 53 | fileResult 54 | 55 | return mutationTestResult 56 | } 57 | } 58 | 59 | const createReportHtml = report => { 60 | return ` 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | Your browser doesn't support custom elements. 69 | Please use a latest version of an evergreen browser (Firefox, Chrome, Safari, Opera, Edge, etc). 70 | 71 | 75 | 76 | ` 77 | } 78 | 79 | /** 80 | * Escapes the HTML tags inside strings in a JSON input by breaking them apart. 81 | */ 82 | function escapeHtmlTags(json: string) { 83 | const j = json.replace(/ 0) { 45 | throw new Error(errors.join('\n')) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/service/mutantGenerator.ts: -------------------------------------------------------------------------------- 1 | import { BoundaryConditionMutator } from '../mutator/boundaryConditionMutator.js' 2 | import { IncrementMutator } from '../mutator/incrementMutator.js' 3 | import { MutationListener } from '../mutator/mutationListener.js' 4 | 5 | import { 6 | ApexLexer, 7 | ApexParser, 8 | ApexParserListener, 9 | CaseInsensitiveInputStream, 10 | CommonTokenStream, 11 | ParseTreeWalker, 12 | } from 'apex-parser' 13 | 14 | import { TokenStreamRewriter } from 'antlr4ts' 15 | import { ApexMutation } from '../type/ApexMutation.js' 16 | 17 | export class MutantGenerator { 18 | private tokenStream?: CommonTokenStream 19 | public compute(classContent: string, coveredLines: Set) { 20 | const lexer = new ApexLexer( 21 | new CaseInsensitiveInputStream('other', classContent) 22 | ) 23 | this.tokenStream = new CommonTokenStream(lexer) 24 | const parser = new ApexParser(this.tokenStream) 25 | const tree = parser.compilationUnit() 26 | 27 | const incrementListener = new IncrementMutator() 28 | const boundaryListener = new BoundaryConditionMutator() 29 | 30 | const listener = new MutationListener( 31 | [incrementListener, boundaryListener], 32 | coveredLines 33 | ) 34 | 35 | ParseTreeWalker.DEFAULT.walk(listener as ApexParserListener, tree) 36 | 37 | return listener.getMutations() 38 | } 39 | 40 | public mutate(mutation: ApexMutation) { 41 | // Create a new token stream rewriter 42 | const rewriter = new TokenStreamRewriter(this.tokenStream!) 43 | // Apply the mutation by replacing the original token with the replacement text 44 | rewriter.replace( 45 | mutation.token.symbol.tokenIndex, 46 | mutation.token.symbol.tokenIndex, 47 | mutation.replacement 48 | ) 49 | 50 | // Get the mutated code 51 | return rewriter.getText() 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/service/mutationTestingService.ts: -------------------------------------------------------------------------------- 1 | import { TestResult } from '@salesforce/apex-node' 2 | import { Connection } from '@salesforce/core' 3 | import { Progress, Spinner } from '@salesforce/sf-plugins-core' 4 | import { ApexClassRepository } from '../adapter/apexClassRepository.js' 5 | import { ApexTestRunner } from '../adapter/apexTestRunner.js' 6 | import { ApexClass } from '../type/ApexClass.js' 7 | import { ApexMutation } from '../type/ApexMutation.js' 8 | import { ApexMutationParameter } from '../type/ApexMutationParameter.js' 9 | import { ApexMutationTestResult } from '../type/ApexMutationTestResult.js' 10 | import { MutantGenerator } from './mutantGenerator.js' 11 | 12 | export class MutationTestingService { 13 | protected readonly apexClassName: string 14 | protected readonly apexTestClassName: string 15 | 16 | constructor( 17 | protected readonly progress: Progress, 18 | protected readonly spinner: Spinner, 19 | protected readonly connection: Connection, 20 | { apexClassName, apexTestClassName }: ApexMutationParameter 21 | ) { 22 | this.apexClassName = apexClassName 23 | this.apexTestClassName = apexTestClassName 24 | } 25 | 26 | public async process() { 27 | const apexClassRepository = new ApexClassRepository(this.connection) 28 | const apexTestRunner = new ApexTestRunner(this.connection) 29 | 30 | this.spinner.start( 31 | `Fetching "${this.apexClassName}" ApexClass content`, 32 | undefined, 33 | { 34 | stdout: true, 35 | } 36 | ) 37 | const apexClass: ApexClass = (await apexClassRepository.read( 38 | this.apexClassName 39 | )) as unknown as ApexClass 40 | this.spinner.stop('Done') 41 | 42 | this.spinner.start( 43 | `Computing coverage from "${this.apexTestClassName}" Test class`, 44 | undefined, 45 | { 46 | stdout: true, 47 | } 48 | ) 49 | const coveredLines = await apexTestRunner.getCoveredLines( 50 | this.apexTestClassName 51 | ) 52 | this.spinner.stop('Done') 53 | 54 | this.spinner.start( 55 | `Generating mutants for "${this.apexClassName}" ApexClass`, 56 | undefined, 57 | { 58 | stdout: true, 59 | } 60 | ) 61 | const mutantGenerator = new MutantGenerator() 62 | const mutations = mutantGenerator.compute(apexClass.Body, coveredLines) 63 | const mutationResults: ApexMutationTestResult = { 64 | sourceFile: this.apexClassName, 65 | sourceFileContent: apexClass.Body, 66 | testFile: this.apexTestClassName, 67 | mutants: [], 68 | } 69 | this.spinner.stop(`${mutations.length} mutations generated`) 70 | 71 | this.progress.start( 72 | mutations.length, 73 | { info: 'Starting mutation testing' }, 74 | { 75 | title: 'MUTATION TESTING PROGRESS', 76 | format: '%s | {bar} | {value}/{total} {info}', 77 | } 78 | ) 79 | 80 | let mutationCount = 0 81 | for (const mutation of mutations) { 82 | const mutatedVersion = mutantGenerator.mutate(mutation) 83 | 84 | this.progress.update(mutationCount, { 85 | info: `Deploying "${mutation.replacement}" mutation at line ${mutation.token.symbol.line}`, 86 | }) 87 | 88 | let progressMessage 89 | try { 90 | await apexClassRepository.update({ 91 | Id: apexClass.Id as string, 92 | Body: mutatedVersion, 93 | }) 94 | 95 | this.progress.update(mutationCount, { 96 | info: `Running tests for "${mutation.replacement}" mutation at line ${mutation.token.symbol.line}`, 97 | }) 98 | const testResult: TestResult = await apexTestRunner.run( 99 | this.apexTestClassName 100 | ) 101 | 102 | const mutantResult = this.buildMutantResult(mutation, testResult) 103 | mutationResults.mutants.push(mutantResult) 104 | 105 | progressMessage = `Mutation result: ${testResult.summary.outcome === 'Pass' ? 'zombie' : 'mutant killed'}` 106 | } catch { 107 | progressMessage = `Issue while computing "${mutation.replacement}" mutation at line ${mutation.token.symbol.line}` 108 | } 109 | ++mutationCount 110 | this.progress.update(mutationCount, { 111 | info: progressMessage, 112 | }) 113 | } 114 | this.progress.finish({ 115 | info: `All mutations evaluated`, 116 | }) 117 | 118 | try { 119 | this.spinner.start( 120 | `Rolling back "${this.apexClassName}" ApexClass to its original state`, 121 | undefined, 122 | { 123 | stdout: true, 124 | } 125 | ) 126 | await apexClassRepository.update(apexClass) 127 | this.spinner.stop('Done') 128 | } catch { 129 | this.spinner.stop('Class not rolled back, please do it manually') 130 | } 131 | 132 | return mutationResults 133 | } 134 | 135 | public calculateScore(mutationResult: ApexMutationTestResult) { 136 | return ( 137 | (mutationResult.mutants.filter(mutant => mutant.status === 'Killed') 138 | .length / 139 | mutationResult.mutants.length) * 140 | 100 || 0 141 | ) 142 | } 143 | 144 | private buildMutantResult(mutation: ApexMutation, testResult: TestResult) { 145 | const token = mutation.token 146 | // TODO Handle NoCoverage 147 | const mutationStatus: 'Killed' | 'Survived' | 'NoCoverage' = 148 | testResult.summary.outcome === 'Pass' ? 'Survived' : 'Killed' 149 | 150 | return { 151 | id: `${this.apexClassName}-${token.symbol.line}-${token.symbol.charPositionInLine}-${token.symbol.tokenIndex}-${Date.now()}`, 152 | mutatorName: mutation.mutationName, 153 | status: mutationStatus, 154 | location: { 155 | start: { 156 | line: token.symbol.line, 157 | column: token.symbol.charPositionInLine, 158 | }, 159 | end: { 160 | line: token.symbol.line, 161 | column: token.symbol.charPositionInLine + mutation.replacement.length, 162 | }, 163 | }, 164 | replacement: mutation.replacement, 165 | original: token.text, 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/type/ApexClass.ts: -------------------------------------------------------------------------------- 1 | export interface ApexClass { 2 | Id: string 3 | Body: string 4 | } 5 | -------------------------------------------------------------------------------- /src/type/ApexMutation.ts: -------------------------------------------------------------------------------- 1 | import { TerminalNode } from 'antlr4ts/tree' 2 | 3 | export interface ApexMutation { 4 | mutationName: string 5 | token: TerminalNode 6 | replacement: string 7 | } 8 | -------------------------------------------------------------------------------- /src/type/ApexMutationParameter.ts: -------------------------------------------------------------------------------- 1 | export interface ApexMutationParameter { 2 | apexClassName: string 3 | apexTestClassName: string 4 | reportDir: string 5 | } 6 | -------------------------------------------------------------------------------- /src/type/ApexMutationTestResult.ts: -------------------------------------------------------------------------------- 1 | export interface ApexMutationTestResult { 2 | sourceFile: string 3 | sourceFileContent: string 4 | testFile: string 5 | mutants: { 6 | id: string 7 | mutatorName: string 8 | status: 'Killed' | 'Survived' | 'NoCoverage' 9 | location: { 10 | start: { line: number; column: number } 11 | end: { line: number; column: number } 12 | } 13 | replacement: string 14 | original: string 15 | }[] 16 | } 17 | -------------------------------------------------------------------------------- /test/data/Mutation.cls: -------------------------------------------------------------------------------- 1 | public class Mutation { 2 | 3 | public static Integer doThings() { 4 | Integer i = 0; 5 | for(;i<10;++i) { 6 | if(i >= 7) { 7 | return i; 8 | } 9 | } 10 | return i; 11 | } 12 | } -------------------------------------------------------------------------------- /test/data/Mutation.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 63.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /test/data/MutationTest.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class MutationTest { 3 | 4 | @IsTest 5 | static void unit_doThing_thenDoNothings() { 6 | // Arrange 7 | System.Debug('Nothing to arrange here'); 8 | 9 | // Act 10 | Integer result = Mutation.doThings(); 11 | 12 | 13 | // Assert 14 | Assert.areEqual(result, 7); 15 | } 16 | } -------------------------------------------------------------------------------- /test/data/MutationTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 63.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /test/integration/run.nut.ts: -------------------------------------------------------------------------------- 1 | import { execCmd } from '@salesforce/cli-plugins-testkit' 2 | import { expect } from 'chai' 3 | import { describe, it } from 'mocha' 4 | import { ApexMutationTestResult } from '../../src/commands/apex/mutation/test/run.js' 5 | 6 | //import { join } from 'node:path' 7 | 8 | describe('apex mutation test run NUTs', () => { 9 | it('should display help', () => { 10 | // Act 11 | const result = execCmd( 12 | 'apex mutation test run --help', 13 | { 14 | ensureExitCode: 0, 15 | } 16 | ).shellOutput 17 | 18 | // Assert 19 | expect(result).to.include('mutation') 20 | }) 21 | 22 | it('should handle invalid class file', async () => { 23 | const result = execCmd( 24 | 'apex mutation test run -o apex-mutation-testing --report-dir reports/nut --apex-class InvalidClass --test-class MutationTest', 25 | { 26 | ensureExitCode: 1, 27 | } 28 | ).shellOutput 29 | 30 | expect(result.stderr).to.include('InvalidClass not found') 31 | }) 32 | 33 | it('should handle invalid test file', async () => { 34 | const result = execCmd( 35 | 'apex mutation test run -o apex-mutation-testing --report-dir reports/nut --apex-class Mutation --test-class InvalidClassTest', 36 | { 37 | ensureExitCode: 1, 38 | } 39 | ).shellOutput 40 | 41 | expect(result.stderr).to.include('InvalidClassTest not found') 42 | }) 43 | 44 | it('should run mutation testing successfully', async () => { 45 | const result = execCmd( 46 | 'apex mutation test run -o apex-mutation-testing --report-dir reports/nut --apex-class Mutation --test-class MutationTest --json', 47 | { 48 | ensureExitCode: 0, 49 | } 50 | ).shellOutput 51 | 52 | const jsonResult = JSON.parse(result.stdout) 53 | 54 | expect(jsonResult.result).to.have.property('score', 100) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /test/unit/adapter/apexClassRepository.test.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from '@salesforce/core' 2 | import { ApexClassRepository } from '../../../src/adapter/apexClassRepository.js' 3 | 4 | describe('ApexClassRepository', () => { 5 | let connectionStub: Connection 6 | let sut: ApexClassRepository 7 | const findMock = jest.fn() 8 | const createMock = jest.fn() 9 | 10 | beforeEach(() => { 11 | connectionStub = { 12 | tooling: { 13 | sobject: () => ({ 14 | find: () => ({ execute: findMock }), 15 | create: createMock, 16 | }), 17 | }, 18 | } as unknown as Connection 19 | sut = new ApexClassRepository(connectionStub) 20 | }) 21 | 22 | afterEach(() => { 23 | jest.clearAllMocks() 24 | }) 25 | 26 | describe('when reading an ApexClass', () => { 27 | describe('given the class exists', () => { 28 | it('then should return the ApexClass', async () => { 29 | // Arrange 30 | const mockApexClass = { 31 | Id: '123', 32 | Name: 'TestClass', 33 | Body: 'public class TestClass {}', 34 | } 35 | findMock.mockResolvedValue([mockApexClass]) 36 | 37 | // Act 38 | const result = await sut.read('TestClass') 39 | 40 | // Assert 41 | expect(result).toEqual(mockApexClass) 42 | expect(findMock).toHaveBeenCalledTimes(1) 43 | }) 44 | }) 45 | 46 | describe('given the class does not exist', () => { 47 | it('then should throw an error', async () => { 48 | // Arrange 49 | findMock.mockRejectedValue( 50 | new Error('ApexClass NonExistentClass not found') 51 | ) 52 | 53 | // Act & Assert 54 | await expect(sut.read('NonExistentClass')).rejects.toThrow( 55 | 'ApexClass NonExistentClass not found' 56 | ) 57 | }) 58 | }) 59 | }) 60 | 61 | describe('when updating an ApexClass', () => { 62 | describe('given the update is successful', () => { 63 | it('then should return the updated ApexClass', async () => { 64 | // Arrange 65 | const mockApexClass = { 66 | Id: '123', 67 | Body: 'public class TestClass {}', 68 | } 69 | createMock.mockResolvedValue({ Id: '123' }) 70 | 71 | // Act 72 | const result = await sut.update(mockApexClass) 73 | 74 | // Assert 75 | expect(result).toEqual({ Id: '123' }) 76 | expect(createMock).toHaveBeenCalledWith( 77 | expect.objectContaining({ 78 | IsCheckOnly: false, 79 | IsRunTests: true, 80 | }) 81 | ) 82 | expect(createMock).toHaveBeenCalledTimes(3) 83 | }) 84 | }) 85 | 86 | describe('given the update fails', () => { 87 | it('then should throw an error', async () => { 88 | // Arrange 89 | const mockApexClass = { 90 | Id: '123', 91 | Body: 'public class TestClass {}', 92 | } 93 | createMock.mockRejectedValue(new Error('Update failed')) 94 | 95 | // Act & Assert 96 | await expect(sut.update(mockApexClass)).rejects.toThrow('Update failed') 97 | }) 98 | }) 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /test/unit/adapter/apexTestRunner.test.ts: -------------------------------------------------------------------------------- 1 | import { TestLevel } from '@salesforce/apex-node' 2 | import { Connection } from '@salesforce/core' 3 | import { ApexTestRunner } from '../../../src/adapter/apexTestRunner.js' 4 | 5 | const runTestAsynchronousMock = jest.fn() 6 | 7 | jest.mock('@salesforce/apex-node', () => { 8 | return { 9 | TestService: jest.fn().mockImplementation(() => { 10 | return { 11 | // Mock the method you want to mock 12 | runTestAsynchronous: runTestAsynchronousMock, 13 | } 14 | }), 15 | } 16 | }) 17 | 18 | describe('ApexTestRunner', () => { 19 | let connectionStub: Connection 20 | let sut: ApexTestRunner 21 | 22 | beforeEach(() => { 23 | connectionStub = {} as Connection 24 | sut = new ApexTestRunner(connectionStub) 25 | }) 26 | 27 | afterEach(() => { 28 | jest.clearAllMocks() 29 | }) 30 | 31 | describe('when getting covered lines', () => { 32 | describe('given the test execution is successful', () => { 33 | it('then should return a set of covered lines', async () => { 34 | // Arrange 35 | const mockTestResult = { 36 | codecoverage: [{ coveredLines: [1, 2, 3] }, { coveredLines: [4, 5] }], 37 | } 38 | runTestAsynchronousMock.mockResolvedValue(mockTestResult) 39 | 40 | // Act 41 | const result = await sut.getCoveredLines('TestClass') 42 | 43 | // Assert 44 | expect(result).toEqual(new Set([1, 2, 3, 4, 5])) 45 | expect(runTestAsynchronousMock).toHaveBeenCalledWith( 46 | { 47 | tests: [{ className: 'TestClass' }], 48 | testLevel: TestLevel.RunSpecifiedTests, 49 | skipCodeCoverage: false, 50 | maxFailedTests: 0, 51 | }, 52 | true 53 | ) 54 | }) 55 | }) 56 | 57 | describe('given the test execution fails', () => { 58 | it('then should throw an error', async () => { 59 | // Arrange 60 | runTestAsynchronousMock.mockRejectedValue( 61 | new Error('Test execution failed') 62 | ) 63 | 64 | // Act & Assert 65 | await expect(sut.getCoveredLines('TestClass')).rejects.toThrow( 66 | 'Test execution failed' 67 | ) 68 | }) 69 | }) 70 | 71 | describe('given there is no code coverage data', () => { 72 | it('then should return an empty set', async () => { 73 | // Arrange 74 | const mockTestResult = { 75 | codecoverage: undefined, 76 | } 77 | runTestAsynchronousMock.mockResolvedValue(mockTestResult) 78 | 79 | // Act 80 | const result = await sut.getCoveredLines('TestClass') 81 | 82 | // Assert 83 | expect(result).toEqual(new Set()) 84 | }) 85 | }) 86 | }) 87 | 88 | describe('when running tests', () => { 89 | describe('given the test execution is successful', () => { 90 | it('then should return the test result', async () => { 91 | // Arrange 92 | const mockTestResult = { 93 | summary: { 94 | outcome: 'Pass', 95 | }, 96 | } 97 | runTestAsynchronousMock.mockResolvedValue(mockTestResult) 98 | 99 | // Act 100 | const result = await sut.run('TestClass') 101 | 102 | // Assert 103 | expect(result).toEqual(mockTestResult) 104 | expect(runTestAsynchronousMock).toHaveBeenCalledWith( 105 | { 106 | tests: [{ className: 'TestClass' }], 107 | testLevel: TestLevel.RunSpecifiedTests, 108 | skipCodeCoverage: true, 109 | maxFailedTests: 0, 110 | }, 111 | false 112 | ) 113 | }) 114 | }) 115 | 116 | describe('given the test execution fails', () => { 117 | it('then should throw an error', async () => { 118 | // Arrange 119 | runTestAsynchronousMock.mockRejectedValue( 120 | new Error('Test execution failed') 121 | ) 122 | 123 | // Act & Assert 124 | await expect(sut.run('TestClass')).rejects.toThrow( 125 | 'Test execution failed' 126 | ) 127 | }) 128 | }) 129 | }) 130 | }) 131 | -------------------------------------------------------------------------------- /test/unit/mutator/boundaryConditionMutator.test.ts: -------------------------------------------------------------------------------- 1 | import { ParserRuleContext, Token } from 'antlr4ts' 2 | import { TerminalNode } from 'antlr4ts/tree/index.js' 3 | import { BoundaryConditionMutator } from '../../../src/mutator/boundaryConditionMutator.js' 4 | 5 | describe('BoundaryConditionMutator', () => { 6 | let sut: BoundaryConditionMutator 7 | let mockCtx: ParserRuleContext 8 | let mockTerminalNode: TerminalNode 9 | 10 | beforeEach(() => { 11 | // Arrange 12 | sut = new BoundaryConditionMutator() 13 | mockCtx = { 14 | childCount: 3, 15 | getChild: jest.fn().mockImplementation(index => { 16 | if (index === 1) { 17 | return { getChild: jest.fn().mockReturnValue(mockTerminalNode) } 18 | } 19 | return {} 20 | }), 21 | } as unknown as ParserRuleContext 22 | mockTerminalNode = { 23 | text: '==', 24 | } as unknown as TerminalNode 25 | }) 26 | 27 | it('should mutate equality operator', () => { 28 | // Arrange 29 | mockTerminalNode = new TerminalNode({ text: '==' } as Token) 30 | 31 | // Act 32 | sut.enterParExpression(mockCtx) 33 | 34 | // Assert 35 | expect(sut['_mutations']).toHaveLength(1) 36 | expect(sut['_mutations'][0].replacement).toBe('!=') 37 | }) 38 | 39 | it('should mutate inequality operator', () => { 40 | // Arrange 41 | mockTerminalNode = new TerminalNode({ text: '!=' } as Token) 42 | 43 | // Act 44 | sut.enterParExpression(mockCtx) 45 | 46 | // Assert 47 | expect(sut['_mutations']).toHaveLength(1) 48 | expect(sut['_mutations'][0].replacement).toBe('==') 49 | }) 50 | 51 | it('should mutate less than operator', () => { 52 | // Arrange 53 | mockTerminalNode = new TerminalNode({ text: '<' } as Token) 54 | 55 | // Act 56 | sut.enterParExpression(mockCtx) 57 | 58 | // Assert 59 | expect(sut['_mutations']).toHaveLength(1) 60 | expect(sut['_mutations'][0].replacement).toBe('<=') 61 | }) 62 | 63 | it('should mutate less than or equal operator', () => { 64 | // Arrange 65 | mockTerminalNode = new TerminalNode({ text: '<=' } as Token) 66 | 67 | // Act 68 | sut.enterParExpression(mockCtx) 69 | 70 | // Assert 71 | expect(sut['_mutations']).toHaveLength(1) 72 | expect(sut['_mutations'][0].replacement).toBe('<') 73 | }) 74 | 75 | it('should mutate greater than operator', () => { 76 | // Arrange 77 | mockTerminalNode = new TerminalNode({ text: '>' } as Token) 78 | 79 | // Act 80 | sut.enterParExpression(mockCtx) 81 | 82 | // Assert 83 | expect(sut['_mutations']).toHaveLength(1) 84 | expect(sut['_mutations'][0].replacement).toBe('>=') 85 | }) 86 | 87 | it('should mutate greater than or equal operator', () => { 88 | // Arrange 89 | mockTerminalNode = new TerminalNode({ text: '>=' } as Token) 90 | 91 | // Act 92 | sut.enterParExpression(mockCtx) 93 | 94 | // Assert 95 | expect(sut['_mutations']).toHaveLength(1) 96 | expect(sut['_mutations'][0].replacement).toBe('>') 97 | }) 98 | 99 | it('should mutate strict equality operator', () => { 100 | // Arrange 101 | mockTerminalNode = new TerminalNode({ text: '===' } as Token) 102 | 103 | // Act 104 | sut.enterParExpression(mockCtx) 105 | 106 | // Assert 107 | expect(sut['_mutations']).toHaveLength(1) 108 | expect(sut['_mutations'][0].replacement).toBe('!==') 109 | }) 110 | 111 | it('should mutate strict inequality operator', () => { 112 | // Arrange 113 | mockTerminalNode = new TerminalNode({ text: '!==' } as Token) 114 | 115 | // Act 116 | sut.enterParExpression(mockCtx) 117 | 118 | // Assert 119 | expect(sut['_mutations']).toHaveLength(1) 120 | expect(sut['_mutations'][0].replacement).toBe('===') 121 | }) 122 | 123 | it('should not mutate when child count is not 3', () => { 124 | // Arrange 125 | mockCtx = { childCount: 2 } as unknown as ParserRuleContext 126 | 127 | // Act 128 | sut.enterParExpression(mockCtx) 129 | 130 | // Assert 131 | expect(sut['_mutations']).toHaveLength(0) 132 | }) 133 | 134 | it('should not mutate when terminal node is not found', () => { 135 | // Arrange 136 | mockCtx.getChild = jest.fn().mockImplementation(() => { 137 | return { getChild: () => null } 138 | }) 139 | 140 | // Act 141 | sut.enterParExpression(mockCtx) 142 | 143 | // Assert 144 | expect(sut['_mutations']).toHaveLength(0) 145 | }) 146 | 147 | it('should not mutate when operator is not in replacement map', () => { 148 | // Arrange 149 | mockTerminalNode = new TerminalNode({ text: 'unknown' } as Token) 150 | 151 | // Act 152 | sut.enterParExpression(mockCtx) 153 | 154 | // Assert 155 | expect(sut['_mutations']).toHaveLength(0) 156 | }) 157 | }) 158 | -------------------------------------------------------------------------------- /test/unit/mutator/incrementMutator.test.ts: -------------------------------------------------------------------------------- 1 | import { ParserRuleContext, Token } from 'antlr4ts' 2 | import { TerminalNode } from 'antlr4ts/tree/TerminalNode.js' 3 | import { IncrementMutator } from '../../../src/mutator/incrementMutator.js' 4 | 5 | describe('IncrementMutator', () => { 6 | let sut: IncrementMutator 7 | 8 | beforeEach(() => { 9 | sut = new IncrementMutator() 10 | }) 11 | 12 | const testCases = [ 13 | { 14 | description: 'post-increment operator', 15 | method: 'enterPostOpExpression', 16 | operator: '++', 17 | expectedReplacement: '--', 18 | }, 19 | { 20 | description: 'post-decrement operator', 21 | method: 'enterPostOpExpression', 22 | operator: '--', 23 | expectedReplacement: '++', 24 | }, 25 | { 26 | description: 'pre-increment operator', 27 | method: 'enterPreOpExpression', 28 | operator: '++', 29 | expectedReplacement: '--', 30 | }, 31 | { 32 | description: 'pre-decrement operator', 33 | method: 'enterPreOpExpression', 34 | operator: '--', 35 | expectedReplacement: '++', 36 | }, 37 | ] 38 | 39 | describe.each(testCases)( 40 | '$method', 41 | ({ method, operator, expectedReplacement }) => { 42 | it(`should add mutation when encountering ${operator} operator`, () => { 43 | // Arrange 44 | const mockCtx = { 45 | childCount: 2, 46 | getChild: jest.fn(index => { 47 | const terminalNode = new TerminalNode({ text: operator } as Token) 48 | return index === 1 ? terminalNode : {} 49 | }), 50 | } as unknown as ParserRuleContext 51 | 52 | // Act 53 | sut[method](mockCtx) 54 | 55 | // Assert 56 | expect(sut['_mutations']).toHaveLength(1) 57 | expect(sut['_mutations'][0].replacement).toBe(expectedReplacement) 58 | }) 59 | } 60 | ) 61 | 62 | const invalidTestCases = [ 63 | { 64 | description: 'child count is not 2', 65 | ctx: { 66 | childCount: 1, 67 | getChild: jest.fn(), 68 | }, 69 | }, 70 | { 71 | description: 'operator is not increment/decrement', 72 | ctx: { 73 | childCount: 2, 74 | getChild: jest.fn().mockImplementation(index => { 75 | return index === 1 ? { text: '+' } : {} 76 | }), 77 | }, 78 | }, 79 | ] 80 | 81 | describe.each(invalidTestCases)('When $description', ({ ctx }) => { 82 | it('should not add mutation', () => { 83 | // Arrange 84 | const mockCtx = ctx as unknown as ParserRuleContext 85 | 86 | // Act 87 | sut['enterPostOpExpression'](mockCtx) 88 | 89 | // Assert 90 | expect(sut['_mutations']).toHaveLength(0) 91 | }) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /test/unit/mutator/mutationListener.test.ts: -------------------------------------------------------------------------------- 1 | import { MethodCallContext } from 'apex-parser' 2 | import { BaseListener } from '../../../src/mutator/baseListener.js' 3 | import { MutationListener } from '../../../src/mutator/mutationListener.js' 4 | 5 | describe('MutationListener', () => { 6 | // Arrange 7 | let mockListener1: jest.Mocked 8 | let mockListener2: jest.Mocked 9 | let sut: MutationListener 10 | let coveredLines: Set 11 | 12 | beforeEach(() => { 13 | mockListener1 = { 14 | enterMethodCall: jest.fn(), 15 | exitMethodCall: jest.fn(), 16 | } as unknown as jest.Mocked 17 | 18 | mockListener2 = { 19 | enterMethodCall: jest.fn(), 20 | exitMethodCall: jest.fn(), 21 | } as unknown as jest.Mocked 22 | 23 | coveredLines = new Set([10, 20, 30]) 24 | sut = new MutationListener([mockListener1, mockListener2], coveredLines) 25 | }) 26 | 27 | it('should propagate enter rule calls to all listeners when line is covered', () => { 28 | // Act 29 | const mockContext = { 30 | start: { line: 10 }, 31 | } as unknown as MethodCallContext 32 | sut['enterMethodCall'](mockContext) 33 | 34 | // Assert 35 | expect(mockListener1['enterMethodCall']).toHaveBeenCalledWith(mockContext) 36 | expect(mockListener2['enterMethodCall']).toHaveBeenCalledWith(mockContext) 37 | }) 38 | 39 | it('should not propagate enter rule calls when line is not covered', () => { 40 | // Act 41 | const mockContext = { 42 | start: { line: 15 }, 43 | } as unknown as MethodCallContext 44 | sut['enterMethodCall'](mockContext) 45 | 46 | // Assert 47 | expect(mockListener1['enterMethodCall']).not.toHaveBeenCalled() 48 | expect(mockListener2['enterMethodCall']).not.toHaveBeenCalled() 49 | }) 50 | 51 | it('should propagate exit rule calls to all listeners when line is covered', () => { 52 | // Act 53 | const mockContext = { 54 | start: { line: 20 }, 55 | } as unknown as MethodCallContext 56 | sut['exitMethodCall'](mockContext) 57 | 58 | // Assert 59 | expect(mockListener1['exitMethodCall']).toHaveBeenCalledWith(mockContext) 60 | expect(mockListener2['exitMethodCall']).toHaveBeenCalledWith(mockContext) 61 | }) 62 | 63 | it('should not propagate exit rule calls when line is not covered', () => { 64 | // Act 65 | const mockContext = { 66 | start: { line: 25 }, 67 | } as unknown as MethodCallContext 68 | sut['exitMethodCall'](mockContext) 69 | 70 | // Assert 71 | expect(mockListener1['exitMethodCall']).not.toHaveBeenCalled() 72 | expect(mockListener2['exitMethodCall']).not.toHaveBeenCalled() 73 | }) 74 | 75 | it('should only call listeners that implement the method', () => { 76 | // Arrange 77 | const mockListener3 = { 78 | // Doesn't implement enterMethodCall 79 | } as unknown as jest.Mocked 80 | sut = new MutationListener([mockListener1, mockListener3], coveredLines) 81 | 82 | // Act 83 | const mockContext = { 84 | start: { line: 10 }, 85 | } as unknown as MethodCallContext 86 | sut['enterMethodCall'](mockContext) 87 | 88 | // Assert 89 | expect(mockListener1['enterMethodCall']).toHaveBeenCalledWith(mockContext) 90 | expect(mockListener3['enterMethodCall']).toBeUndefined() 91 | }) 92 | 93 | it('should handle undefined context gracefully', () => { 94 | // Act 95 | sut['enterMethodCall'](undefined as unknown as MethodCallContext) 96 | 97 | // Assert 98 | expect(mockListener1['enterMethodCall']).not.toHaveBeenCalled() 99 | expect(mockListener2['enterMethodCall']).not.toHaveBeenCalled() 100 | }) 101 | 102 | it('should handle context with undefined start line gracefully', () => { 103 | // Act 104 | const mockContext = {} as MethodCallContext 105 | sut['enterMethodCall'](mockContext) 106 | 107 | // Assert 108 | expect(mockListener1['enterMethodCall']).not.toHaveBeenCalled() 109 | expect(mockListener2['enterMethodCall']).not.toHaveBeenCalled() 110 | }) 111 | }) 112 | -------------------------------------------------------------------------------- /test/unit/reporter/HTMLReporter.test.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from 'node:fs/promises' 2 | import { ApexMutationHTMLReporter } from '../../../src/reporter/HTMLReporter.js' 3 | import { ApexMutationTestResult } from '../../../src/type/ApexMutationTestResult.js' 4 | 5 | jest.mock('node:fs/promises') 6 | 7 | describe('HTMLReporter', () => { 8 | let sut: ApexMutationHTMLReporter 9 | const testResults: ApexMutationTestResult = { 10 | sourceFile: 'TestClass', 11 | sourceFileContent: 'public class TestClass {}', 12 | testFile: 'TestClass_Test', 13 | mutants: [ 14 | { 15 | id: '1', 16 | mutatorName: 'IncrementMutator', 17 | status: 'Killed', 18 | location: { 19 | start: { line: 1, column: 0 }, 20 | end: { line: 1, column: 10 }, 21 | }, 22 | replacement: '--', 23 | original: '++', 24 | }, 25 | { 26 | id: '2', 27 | mutatorName: 'BoundaryConditionMutator', 28 | status: 'Survived', 29 | location: { 30 | start: { line: 2, column: 0 }, 31 | end: { line: 2, column: 10 }, 32 | }, 33 | replacement: '>=', 34 | original: '<', 35 | }, 36 | ], 37 | } 38 | 39 | beforeEach(() => { 40 | sut = new ApexMutationHTMLReporter() 41 | }) 42 | 43 | describe('when generating report', () => { 44 | it('should generate HTML content with mutation results', async () => { 45 | // Act 46 | await sut.generateReport(testResults) 47 | 48 | // Assert 49 | expect(writeFile).toHaveBeenCalledWith( 50 | expect.any(String), 51 | expect.stringContaining('') 52 | ) 53 | }) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /test/unit/service/apexClassValidator.test.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from '@salesforce/core' 2 | import { ApexClassRepository } from '../../../src/adapter/apexClassRepository.js' 3 | import { ApexClassValidator } from '../../../src/service/apexClassValidator.js' 4 | import { ApexClass } from '../../../src/type/ApexClass.js' 5 | 6 | jest.mock('../../../src/adapter/apexClassRepository.js') 7 | const readMock = jest.fn() 8 | 9 | describe('ApexClassValidator', () => { 10 | let sut: ApexClassValidator 11 | const params = { 12 | apexClassName: 'TestClass', 13 | apexTestClassName: 'TestClassTest', 14 | reportDir: 'reports', 15 | } 16 | 17 | beforeEach(() => { 18 | // Arrange 19 | ;(ApexClassRepository as jest.Mock).mockImplementation(() => ({ 20 | read: readMock, 21 | })) 22 | readMock.mockReset() 23 | 24 | sut = new ApexClassValidator({} as jest.Mocked) 25 | }) 26 | 27 | describe('validate', () => { 28 | it('should throw error when apex class is not found', async () => { 29 | // Arrange 30 | readMock.mockResolvedValueOnce(null) 31 | 32 | // Act & Assert 33 | await expect(sut.validate(params)).rejects.toThrow( 34 | 'Apex class TestClass not found' 35 | ) 36 | }) 37 | 38 | it('should throw error when apex test class is not found', async () => { 39 | // Arrange 40 | const mockApexClass = { Body: 'class TestClass {}' } 41 | readMock 42 | .mockResolvedValueOnce(mockApexClass as ApexClass) 43 | .mockResolvedValueOnce(null) 44 | 45 | // Act & Assert 46 | await expect(sut.validate(params)).rejects.toThrow( 47 | 'Apex test class TestClassTest not found' 48 | ) 49 | }) 50 | 51 | it('should throw error when apex test class is not annotated with @isTest', async () => { 52 | // Arrange 53 | const mockApexClass = { Body: 'class TestClass {}' } 54 | const mockTestClass = { Body: 'class TestClassTest {}' } 55 | readMock 56 | .mockResolvedValueOnce(mockApexClass as ApexClass) 57 | .mockResolvedValueOnce(mockTestClass as ApexClass) 58 | // Act & Assert 59 | await expect(sut.validate(params)).rejects.toThrow( 60 | 'Apex test class TestClassTest is not annotated with @isTest' 61 | ) 62 | }) 63 | 64 | it('should not throw error when both classes are valid', async () => { 65 | // Arrange 66 | const mockApexClass = { Body: 'class TestClass {}' } 67 | const mockTestClass = { Body: '@IsTest class TestClassTest {}' } 68 | readMock 69 | .mockResolvedValueOnce(mockApexClass as ApexClass) 70 | .mockResolvedValueOnce(mockTestClass as ApexClass), 71 | // Act & Assert 72 | await expect(sut.validate(params)).resolves.not.toThrow() 73 | }) 74 | }) 75 | }) 76 | -------------------------------------------------------------------------------- /test/unit/service/mutantGenerator.test.ts: -------------------------------------------------------------------------------- 1 | import { MutantGenerator } from '../../../src/service/mutantGenerator.js' 2 | 3 | describe('MutantGenerator', () => { 4 | let sut: MutantGenerator 5 | 6 | beforeEach(() => { 7 | sut = new MutantGenerator() 8 | }) 9 | 10 | describe('when computing mutations', () => { 11 | it('should return an array of mutations for covered lines', () => { 12 | // Arrange 13 | const classContent = 14 | 'public class Mutation { public static void mutate() { for(Integer i = 0 ; i < 10 ; ++i){} } }' 15 | const coveredLines = new Set([1]) // Line 1 is covered 16 | 17 | // Act 18 | const result = sut.compute(classContent, coveredLines) 19 | 20 | // Assert 21 | expect(result).toHaveLength(1) 22 | expect(result[0].replacement).toEqual('--') 23 | }) 24 | 25 | it('should return empty array for uncovered lines', () => { 26 | // Arrange 27 | const classContent = 28 | 'public class Mutation { public static void mutate() { for(Integer i = 0 ; i < 10 ; ++i){} } }' 29 | const coveredLines = new Set([2]) // Line 1 is not covered 30 | 31 | // Act 32 | const result = sut.compute(classContent, coveredLines) 33 | 34 | // Assert 35 | expect(result).toHaveLength(0) 36 | }) 37 | }) 38 | 39 | describe('when mutating code', () => { 40 | it('should return mutated code with replacement applied', () => { 41 | // Arrange 42 | const classContent = 43 | 'public class Mutation { public static void mutate() { for(Integer i = 0 ; i < 10 ; ++i){} } }' 44 | const coveredLines = new Set([1]) 45 | const mutation = sut.compute(classContent, coveredLines) 46 | 47 | // Act 48 | const result = sut.mutate(mutation[0]) 49 | 50 | // Assert 51 | expect(result).toEqual( 52 | 'public class Mutation { public static void mutate() { for(Integer i = 0 ; i < 10 ; --i){} } }' 53 | ) 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /test/unit/service/mutationTestingService.test.ts: -------------------------------------------------------------------------------- 1 | import { TestResult } from '@salesforce/apex-node' 2 | import { Connection } from '@salesforce/core' 3 | import { Progress, Spinner } from '@salesforce/sf-plugins-core' 4 | import { ApexClassRepository } from '../../../src/adapter/apexClassRepository.js' 5 | import { ApexTestRunner } from '../../../src/adapter/apexTestRunner.js' 6 | import { MutantGenerator } from '../../../src/service/mutantGenerator.js' 7 | import { MutationTestingService } from '../../../src/service/mutationTestingService.js' 8 | import { ApexMutationParameter } from '../../../src/type/ApexMutationParameter.js' 9 | import { ApexMutationTestResult } from '../../../src/type/ApexMutationTestResult.js' 10 | 11 | jest.mock('../../../src/adapter/apexClassRepository.js') 12 | jest.mock('../../../src/adapter/apexTestRunner.js') 13 | jest.mock('../../../src/service/mutantGenerator.js') 14 | 15 | describe('MutationTestingService', () => { 16 | let sut: MutationTestingService 17 | let progress: Progress 18 | let spinner: Spinner 19 | let connection: Connection 20 | 21 | const mockApexClass = { 22 | Id: '123', 23 | Name: 'TestClass', 24 | Body: 'class TestClass { }', 25 | } 26 | 27 | const mockMutation = { 28 | mutationName: 'TestMutation', 29 | replacement: 'newCode', 30 | token: { 31 | text: 'oldCode', 32 | symbol: { 33 | line: 1, 34 | charPositionInLine: 0, 35 | tokenIndex: 0, 36 | }, 37 | }, 38 | } 39 | 40 | beforeEach(() => { 41 | progress = { 42 | start: jest.fn(), 43 | update: jest.fn(), 44 | finish: jest.fn(), 45 | } as unknown as Progress 46 | 47 | spinner = { 48 | start: jest.fn(), 49 | stop: jest.fn(), 50 | } as unknown as Spinner 51 | 52 | connection = {} as Connection 53 | 54 | sut = new MutationTestingService(progress, spinner, connection, { 55 | apexClassName: 'TestClass', 56 | apexTestClassName: 'TestClassTest', 57 | } as ApexMutationParameter) 58 | }) 59 | 60 | describe('Given a mutation testing service', () => { 61 | describe('When processing mutations', () => { 62 | const testCases = [ 63 | { 64 | description: 'when test is failing', 65 | testResult: { summary: { outcome: 'Fail' } } as TestResult, 66 | expectedStatus: 'Killed', 67 | error: null, 68 | updateError: null, 69 | expectedMutants: [ 70 | expect.objectContaining({ 71 | mutatorName: 'TestMutation', 72 | status: 'Killed', 73 | replacement: 'newCode', 74 | original: 'oldCode', 75 | }), 76 | ], 77 | }, 78 | { 79 | description: 'when test is passing', 80 | testResult: { summary: { outcome: 'Pass' } } as TestResult, 81 | expectedStatus: 'Survived', 82 | error: null, 83 | updateError: null, 84 | expectedMutants: [ 85 | expect.objectContaining({ 86 | mutatorName: 'TestMutation', 87 | status: 'Survived', 88 | replacement: 'newCode', 89 | original: 'oldCode', 90 | }), 91 | ], 92 | }, 93 | { 94 | description: 'when test runner throws exception', 95 | testResult: null, 96 | expectedStatus: 'Survived', 97 | error: new Error('Test runner failed'), 98 | updateError: null, 99 | expectedMutants: [], 100 | }, 101 | { 102 | description: 'when update fails', 103 | testResult: {}, 104 | expectedStatus: 'Survived', 105 | error: null, 106 | updateError: new Error('Update failed'), 107 | expectedMutants: [], 108 | }, 109 | ] 110 | 111 | it.each(testCases)( 112 | 'should handle $description', 113 | async ({ testResult, expectedMutants, error, updateError }) => { 114 | // Arrange 115 | ;(ApexClassRepository as jest.Mock).mockImplementation(() => ({ 116 | read: jest.fn().mockResolvedValue(mockApexClass), 117 | update: jest 118 | .fn() 119 | [updateError ? 'mockRejectedValue' : 'mockResolvedValue']( 120 | updateError || {} 121 | ), 122 | })) 123 | ;(MutantGenerator as jest.Mock).mockImplementation(() => ({ 124 | compute: jest.fn().mockReturnValue([mockMutation]), 125 | mutate: jest.fn().mockReturnValue('mutated code'), 126 | })) 127 | ;(ApexTestRunner as jest.Mock).mockImplementation(() => ({ 128 | run: jest 129 | .fn() 130 | [error ? 'mockRejectedValue' : 'mockResolvedValue']( 131 | error || testResult 132 | ), 133 | getCoveredLines: jest.fn().mockResolvedValue(new Set([1])), 134 | })) 135 | 136 | // Act 137 | const result = await sut.process() 138 | 139 | // Assert 140 | expect(result).toEqual({ 141 | sourceFile: 'TestClass', 142 | sourceFileContent: mockApexClass.Body, 143 | testFile: 'TestClassTest', 144 | mutants: expectedMutants, 145 | }) 146 | expect(spinner.start).toHaveBeenCalledTimes(4) 147 | expect(spinner.stop).toHaveBeenCalledTimes(4) 148 | expect(progress.start).toHaveBeenCalled() 149 | expect(progress.finish).toHaveBeenCalled() 150 | } 151 | ) 152 | }) 153 | 154 | describe('When calculating mutation score', () => { 155 | const scoreTestCases = [ 156 | { 157 | description: 'with kills', 158 | mutants: [ 159 | { status: 'Killed' }, 160 | { status: 'Survived' }, 161 | { status: 'Killed' }, 162 | ], 163 | expectedScore: 66.66666666666666, 164 | }, 165 | { 166 | description: 'with no mutants', 167 | mutants: [], 168 | expectedScore: 0, 169 | }, 170 | ] 171 | 172 | it.each(scoreTestCases)( 173 | 'should calculate correct score $description', 174 | ({ mutants, expectedScore }) => { 175 | // Arrange 176 | const mockResult = { 177 | sourceFile: 'TestClass', 178 | sourceFileContent: 'content', 179 | testFile: 'TestClassTest', 180 | mutants, 181 | } as ApexMutationTestResult 182 | 183 | // Act 184 | const score = sut.calculateScore(mockResult) 185 | 186 | // Assert 187 | expect(score).toBe(expectedScore) 188 | } 189 | ) 190 | }) 191 | }) 192 | }) 193 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@salesforce/dev-config/tsconfig-strict-esm", 3 | "compilerOptions": { 4 | "allowJs": false, 5 | "allowUnreachableCode": false, 6 | "allowUnusedLabels": false, 7 | "alwaysStrict": true, 8 | "declaration": true, 9 | "exactOptionalPropertyTypes": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "importHelpers": true, 12 | "noImplicitAny": false, 13 | "noImplicitOverride": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noPropertyAccessFromIndexSignature": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "outDir": "./lib", 20 | "resolveJsonModule": true, 21 | "rootDir": "src", 22 | "skipLibCheck": true, 23 | "strictBindCallApply": true, 24 | "strictFunctionTypes": true, 25 | "strictNullChecks": true, 26 | "strictPropertyInitialization": true, 27 | "useUnknownInCatchVariables": true 28 | }, 29 | "include": [ 30 | "./src/**/*.json", 31 | "./src/**/*" 32 | ] 33 | } --------------------------------------------------------------------------------