Why code coverage is a trap and mutation testing is salvation
\n\nImagine a situation: your Kotlin project has 95% test coverage. The CI is green, the manager is happy, the code goes to production. And a week later — a bug in production. Sound familiar? Most likely, your tests simply run, but don't verify the logic. This is where mutation testing comes onto the scene — a technique that doesn't let tests "slack off."
\n\nIn this article, we'll break down what mutation testing in Kotlin is, how it works, and how to integrate it into a project using the popular tool Pitest. You'll learn why 100% code coverage is not a guarantee of quality, and how mutation testing helps write truly reliable tests.
\n\nWhat is mutation testing? Explaining with cats
\n\nLet's say you wrote a function that determines if a cat can fit through a door:
\n\nfun canCatFitThroughDoor(catWidth: Int, doorWidth: Int): Boolean {\n return catWidth <= doorWidth\n}\n\nYou wrote a test:
\n\n@Test\nfun `cat width 30 fits through door 40`() {\n assertTrue(canCatFitThroughDoor(30, 40))\n}\n\nCoverage — 100%. But what if someone accidentally changes <= to <? The test will still pass! Mutation testing automatically introduces such "small breakages" (mutations) into the code and checks if at least one test fails. If the test doesn't fail — your tests don't catch this error. This is mutation testing in Kotlin.
How mutation testing works: from operators to "surviving" mutants
\n\nThe tool (e.g., Pitest for Kotlin) creates mutants — versions of your code with one change. Here are typical mutations:
\n\n- \n
- Changing comparison operators:
>→>=,==→!=\n - Inverting conditions:
if (a > b)→if (a <= b)\n - Removing method calls:
someMethod()→ void \n - Substituting return values:
true→false\n - Changing numbers:
10→11or0\n
After creating the mutants, all your tests are run. The results fall into three categories:
\n\n- \n
- Killed mutants — tests failed, mutation detected. Excellent! \n
- Survived mutants — tests passed, even though the code was changed. A warning sign. \n
- Covered but not killed — tests ran but didn't notice the change. \n
Your goal is to have as few surviving mutants as possible (ideally 0). This is mutation testing of Kotlin code in action.
\n\nPitest: the main tool for Kotlin
\n\nFor JVM languages (Java, Kotlin), the de facto standard is Pitest (PIT Mutation Testing). It supports Kotlin via the pitest-kotlin plugin. Here's how to add it in Gradle:
plugins {\n id("info.solidsoft.pitest") version "1.15.0"\n}\n\npitest {\n targetClasses.set(listOf("com.myapp.*")) // which classes to mutate\n targetTests.set(listOf("com.myapp.*")) // which tests to run\n threads.set(4) // parallel threads\n outputFormats.set(listOf("XML", "HTML")) // reports\n mutationThreshold.set(80) // minimum percentage killed\n timeoutConstInMillis.set(10000) // test timeout\n}\n\ndependencies {\n testImplementation("org.pitest:pitest-kotlin:1.15.0")\n}\n\nRun with one command:
\n\n./gradlew pitest\n\nAfter execution, in build/reports/pites