Your Tests Are Lying, and You Don't Even Know It
\n\nImagine this: you've written unit tests, code coverage is at 95%. Your manager is happy, the CI/CD pipeline is green. But on Friday evening, a bug slips into production. Sound familiar?
\n\nThe problem is that classic code coverage is an illusion. It shows which lines of code were executed, but it doesn't answer the main question: do the tests actually verify the logic? This is where mutation testing comes into play — a technique that ruthlessly "breaks" your code to check how good your tests really are.
\n\nIn this article, we'll break down what mutation testing in Java is, how it works, and show real examples using the PIT (Pitest) tool. You'll learn how to turn your tests from a "checkbox in a report" into real protection against bugs.
\n\nWhat Is Mutation Testing and Why Is It Important?
\n\nMutation testing is a method for evaluating test quality where small, deliberately incorrect changes (called mutations) are introduced into the source code. Then your tests are run. If at least one test fails — the mutant is "killed," the test worked. If the tests pass — the mutant "survived," and that's a red flag.
\n\nA simple metaphor: you're checking how well your alarm system catches thieves. You plant a "thief" (mutant) and see if the alarm (test) goes off. If the thief walks away calmly — the alarm is useless.
\n\nWhy Is Code Coverage a Bad Metric?
\n\nConsider this code:
\n\npublic boolean isAdult(int age) {\n if (age >= 18) {\n return true;\n }\n return false;\n}\n\nAnd the test:
\n\n@Test\nvoid testIsAdult() {\n assertTrue(isAdult(18));\n}\n\nCode coverage will show 100% (both if-else branches executed?). No! Only the true branch was executed. But the test is green. Now imagine someone accidentally changes the condition to age > 21. The test will still pass because 18 > 21 is false, and assertTrue expects true. The bug goes into production.
Mutation testing would instantly catch this situation by creating a mutant that changes >= to > or inverts the condition.
How Mutation Testing Works: Theory in Simple Terms
\n\nThe process consists of three stages:
\n\n- \n
- Mutant generation. The tool (e.g., PIT) creates multiple versions of your code with small changes: it changes operators, removes method calls, inverts conditions. \n
- Running tests. Your unit tests are run against each mutant. \n
- Analyzing results. If a test fails — the mutant is killed. If it passes — it's alive. The percentage of killed mutants is called the mutation score. \n
A good score: 80-90% killed mutants. Anything lower is a reason to rewrite your tests.
\n\nPractical Example in Java with PIT
\n\nLet's take a real example. Suppose we have a service for calculating discounts:
\n\npublic class DiscountService {\n public double calculateDiscount(double amount, boolean isPremium) {\n double discount = 0.0;\n\n if (amount > 100) {\n discount = 0.1; // 10% discount\n }\n\n if (isPremium) {\n discount += 0.05; // additional 5%\n }\n\n // Discount cannot exceed 30%\n if (discount > 0.3) {\n discount = 0.3;\n }\n\n return discount;\n }\n}\n\nLet's write a naive test:
\n\nimport org.junit.jupiter.api.Test;\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass DiscountServiceTest {\n\n private final DiscountService service = new