Post

Mutation Testing for Angular with Stryker: When 100% Coverage Still Tests Nothing

Mutation Testing for Angular with Stryker: When 100% Coverage Still Tests Nothing

A few weeks ago I wrote about mutation testing for Java with PIT: why 100% line coverage can still catch nothing, and how deliberately injecting small bugs, mutants, measures whether your tests would actually notice. If you want the full case for why coverage lies, start there. This post is the other half of the stack: the same sensor, applied to Angular with Stryker Mutator.

The frontend is where this matters most. expect(component).toBeTruthy() is practically its own genre of Angular test. It instantiates a component, runs its constructor and half of ngOnInit, pushes coverage past 60%, and asserts nothing about what the component actually does. A whole suite of these reports green whether the code works or not, and line coverage will happily call it well-tested. Mutation testing is the sensor that sees through them.

It is the same harness move as the rest of this series: take an assumption, “our tests would catch a regression,” and turn it into a number the build can gate on. Here we run Stryker against the real CRUD project I use across this blog, read a genuine surviving mutant it finds, and wire the mutation score into CI as a gate.

Want the code? Every example in this post targets the full-stack CRUD project I use across this blog and my videos: loiane/crud-angular-spring

In this post, we cover:

  • A quick refresher on what mutation testing measures: mutants, killed vs survived, and the mutation score (the full why lives in the PIT post)
  • Setting up Stryker in a modern standalone Angular project
  • The test-runner decision that actually matters: Stryker’s command runner vs the dedicated Vitest runner, and why ng test on the new @angular/build:unit-test builder changes the calculus
  • Reading a real Stryker report and a genuine surviving mutant from this codebase, one that exposes redundant code coverage completely hid
  • Killing method and URL mutants in an HTTP service, and why HttpTestingController is good at it
  • Why AI-generated tests survive mutants at an alarming rate, with sample prompts to fix that
  • Running Stryker as a gate in GitHub Actions
  • Keeping mutation testing fast enough to actually live in your pipeline

A Quick Refresher

The PIT post makes the full case, so here is the one-paragraph version. Line coverage tells you a line ran under test; it never asks whether an assertion would have failed if that line misbehaved. Mutation testing asks exactly that: it makes hundreds of tiny broken copies of your code (mutants, where return 'code' becomes return '' or if (record._id) becomes if (true)), runs your suite against each, and reports which ones your tests killed (a test failed) versus which survived (every test still passed). The percentage killed is your mutation score, and unlike coverage it cannot be inflated by tests that run code without checking it. A surviving mutant is a precise message: here is a change to your production code that none of your tests would catch.

Setting Up Stryker

Stryker is the mutation testing framework for the JavaScript and TypeScript ecosystem, the Angular counterpart to PIT on the JVM. Install the core package as a dev dependency:

1
npm install --save-dev @stryker-mutator/core

Then scaffold a config with npx stryker init, or write it by hand. Here is the configuration this project uses, in stryker.config.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
  "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
  "mutate": [
    "src/app/**/*.ts",
    "!src/**/*.spec.ts"
  ],
  "testRunner": "command",
  "commandRunner": {
    "command": "npm run test:ci"
  },
  "coverageAnalysis": "off",
  "thresholds": { "high": 80, "low": 60, "break": 50 },
  "concurrency": 2,
  "reporters": ["html", "clear-text", "progress"],
  "htmlReporter": {
    "fileName": "reports/mutation/mutation.html"
  },
  "tempDirName": ".stryker-tmp",
  "cleanTempDir": true
}

Wire it to a script in package.json and you have a one-command mutation run:

1
2
3
4
5
6
{
  "scripts": {
    "test:ci": "ng test --watch=false --progress=false",
    "test:mutation": "stryker run"
  }
}

A few fields carry most of the weight. mutate defines what gets sabotaged: all app code, never the spec files. thresholds.break is the gate: a mutation score below 50 exits non-zero and fails CI, the same way a failing test would. high and low only color the report. And testRunner is the decision worth understanding, because in the Angular world it is not obvious.

The Test Runner Decision

Stryker has to run your tests hundreds of times, once per mutant, so how it invokes them matters more here than anywhere else. It offers two paths for an Angular project, and this repo deliberately takes the simpler one.

The command runner, what the config above uses, treats your test command as a black box. Stryker swaps in a mutant, runs npm run test:ci, and watches the exit code: non-zero means killed, zero means survived. That command is ng test --watch=false, which on modern Angular (this project is on v22) runs through the new @angular/build:unit-test builder, Angular’s Vitest-based test runner, configured in angular.json:

1
2
3
"test": {
  "builder": "@angular/build:unit-test"
}

The virtue of the command runner is that it does not care what is underneath ng test. Karma, the new Vitest builder, whatever Angular ships next: Stryker just runs the command and reads the result. It is the most robust choice while Angular’s testing story is still in motion, and it is why it is the sensible default for a real project today.

The cost is speed. Because Stryker cannot see inside the command, it must run the entire suite for every mutant, which is exactly why the config sets coverageAnalysis: "off". There is no way to map which test covers which mutant through an opaque shell command, so every mutant pays for the whole suite.

The dedicated Vitest runner (@stryker-mutator/vitest-runner) is the faster alternative. By hooking directly into Vitest, Stryker can enable coverageAnalysis: "perTest": for each mutant, it runs only the tests that actually exercise the mutated line, which can cut a mutation run dramatically. The tradeoff is tighter coupling: you configure Stryker against Vitest directly rather than letting ng test abstract it. It is the natural upgrade once mutation runtime starts to hurt, and it is worth reaching for the moment the full-suite-per-mutant cost outweighs the simplicity.

This project keeps @stryker-mutator/vitest-runner in its dev dependencies for exactly that reason: the command runner is what the config wires up today, but the faster runner is already installed and ready to switch to the moment the runtime justifies it. Start with the command runner for its robustness; graduate to the Vitest runner when speed demands it.

Reading a Real Report

Run npm run test:mutation and Stryker prints a clear-text summary and writes an HTML report to reports/mutation/mutation.html. The summary looks like this (numbers illustrative):

1
2
3
4
5
6
7
8
9
------------------------|---------|----------|------------|----------|
File                    | % score | # killed | # survived | # no cov |
------------------------|---------|----------|------------|----------|
All files               |   88.24 |       45 |          6 |        0 |
 courses/services       |         |          |            |          |
  courses.ts            |  100.00 |       12 |          0 |        0 |
 shared/pipes           |         |          |            |          |
  category-pipe.ts      |   62.50 |        5 |          3 |        0 |
------------------------|---------|----------|------------|----------|

The aggregate number is fine, but the aggregate is not where the value is. The value is in the survivors, and this codebase has a genuinely instructive one. Look at the CategoryIconPipe, which maps a course category to a Material icon:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Pipe({
  name: 'categoryIcon',
})
export class CategoryIconPipe implements PipeTransform {
  transform(value: string): string {
    switch (value?.toLowerCase()) {
      case 'front-end':
        return 'code';
      case 'back-end':
        return 'computer';
    }
    return 'code';
  }
}

Its spec is thorough by every conventional measure: it covers every case, casing variations, an unknown category, and an empty string:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
it('should return "code" for "front-end"', () => {
  expect(pipe.transform('front-end')).toBe('code');
});
it('should return "computer" for "back-end"', () => {
  expect(pipe.transform('back-end')).toBe('computer');
});
it('should match categories regardless of casing', () => {
  expect(pipe.transform('Front-end')).toBe('code');
  expect(pipe.transform('Back-end')).toBe('computer');
});
it('should return "code" as default for unknown category', () => {
  expect(pipe.transform('full-stack')).toBe('code');
});
it('should return "code" for empty string', () => {
  expect(pipe.transform('')).toBe('code');
});

That is 100% line coverage and 100% branch coverage. Stryker still finds survivors, and each one tells you something coverage could not.

Survivor 1: The Redundant Branch

Stryker mutates the string literal 'front-end' into '' and reports it survived:

1
2
3
4
#1. [Survived] StringLiteral
src/app/shared/pipes/category-pipe.ts:9:12
-       case 'front-end':
+       case '':

Think about why no test catches this. With the mutation in place, transform('front-end') no longer matches the case, so it falls through to return 'code' and returns 'code', exactly what the test expects. The test passes. The mutant lives.

That survivor is not noise; it is a design smell the mutation score dragged into the light. The case 'front-end' branch returns 'code', and the default also returns 'code'. The branch is redundant: you could delete it entirely and every test would still pass, because it does the same thing as the fallthrough. Coverage marked that line as tested. Mutation testing revealed it is untestable as written, because it has no distinct observable behavior to test. The honest fix is to delete the redundant case, not to add a test for a branch that does nothing.

Survivor 2: The Unguarded Optional Chain

Stryker also mutates the optional chaining operator, removing the ?:

1
2
3
4
#2. [Survived] OptionalChaining
src/app/shared/pipes/category-pipe.ts:8:20
-     switch (value?.toLowerCase()) {
+     switch (value.toLowerCase()) {

This one survives because not a single test calls transform(null) or transform(undefined). With ?. the pipe safely returns the default for a nullish input; without it, value.toLowerCase() throws. No test explores that path, so removing the safety operator changes nothing any assertion observes. This is the more valuable kind of survivor: a real, unguarded case in production code. The fix here is a new test:

1
2
3
it('should return "code" for a nullish category', () => {
  expect(pipe.transform(null as unknown as string)).toBe('code');
});

Add that, and the optional-chaining mutant dies, because now removing the ?. makes a test throw. The mutation score went up because your suite genuinely got stronger, not because a number moved.

Killing Mutants in an HTTP Service

Not every part of the codebase is riddled with survivors, and seeing what good coverage looks like under mutation is just as instructive. The CoursesService decides between create and update based on whether an id is present:

1
2
3
4
5
6
save(record: Partial<Course>): Observable<Course> {
  if (record._id) {
    return this.update(record); // PUT
  }
  return this.create(record);   // POST
}

Stryker attacks that if with two conditional mutants: forcing the condition to true (always update) and to false (always create). Both die, because the spec asserts on the actual HTTP method for each path using HttpTestingController:

1
2
3
4
5
6
7
8
9
10
11
12
13
it('should POST to create a course when _id is absent', () => {
  service.save({ name: 'New', category: 'back-end' }).subscribe();
  const req = httpMock.expectOne('/api/courses');
  expect(req.request.method).toBe('POST');
  req.flush({ _id: '2', name: 'New', category: 'back-end' });
});

it('should PUT to update a course when _id is present', () => {
  service.save(mockCourse).subscribe();
  const req = httpMock.expectOne('/api/courses/1');
  expect(req.request.method).toBe('PUT');
  req.flush(mockCourse);
});

This is why HttpTestingController is such a strong ally for mutation score: expectOne('/api/courses/1') pins the exact URL, and expect(req.request.method).toBe('PUT') pins the exact verb. Mutate the URL string, and expectOne fails on an unexpected request. Mutate the method, and the assertion fails. Force the conditional true, and the “create” test suddenly sees a PUT to /api/courses/undefined instead of a POST: dead mutant. Assertions that check what actually went over the wire kill mutants that assertions like toBeTruthy() never could. The lesson generalizes: the more specifically a test describes the behavior it expects, the more mutants it kills.

Why AI-Generated Tests Survive Mutants

I have made this argument for every sensor in this series, and for mutation testing on the frontend it is almost too easy to demonstrate, because the failure mode is visible in the shape of the test itself.

When you ask an agent to “add tests for this component,” it optimizes for the thing it can see: making the file exist, making it run, making coverage go up. The path of least resistance to all three is a test that instantiates the component and asserts it is truthy, or one that calls a method and checks the result is defined. These tests execute a lot of lines and assert almost nothing; they are coverage-maximizing and mutant-transparent. Worse, an agent that writes the implementation and the test in the same breath tends to write tests that mirror the implementation rather than specify the behavior: if the code returns 'code', the test asserts 'code', and if the code were wrong the test would happily assert the wrong value with equal confidence.

The framing is the same as always. A line in copilot-instructions.md that says “write meaningful tests, assert on behavior not existence” is an inferential feedforward guide: it nudges the model, and the model forgets it the moment coverage looks green. The mutation score is a computational feedback sensor: it fails the build when the tests do not actually test, whether they were written by a person racing a deadline or an agent racing to green.

The practical move is to feed survivors back to the agent as concrete work. A toBeTruthy test is a vague instruction; a surviving mutant is a precise one. Sample prompts I use:

1
2
3
4
5
6
Run `npm run test:mutation` on the Angular project and open the Stryker report.
For every SURVIVED mutant, add or strengthen a test that kills it.
Assert on the specific behavior the mutant changes, not on existence or
truthiness. Do not weaken the code to make a mutant equivalent. If a mutant
is genuinely equivalent (no observable difference), tell me and explain why
instead of adding a test.

And a prompt that turns the score into an acceptance criterion:

1
2
3
4
5
Treat the mutation score as acceptance criteria, not coverage. The task is
not done until `npm run test:mutation` passes the break threshold (50) and
you have shown me the surviving-mutant list is either empty or contains only
mutants you have justified as equivalent. A green line-coverage report is
not sufficient evidence that the tests work.

That second prompt reframes the whole exercise. “Write good tests” is a hope; “kill these mutants” is a checklist the agent has to work through, using the exact sensor the pipeline will run.

Running Stryker in GitHub Actions

Mutation testing is an ordinary CI job, and it sits alongside the other sensors from this series in the same pipeline. Because it is slower than a normal test run (every mutant runs the suite under the command runner), many teams run it on a schedule or only on pull requests that touch app code, rather than on every push. As a straight gating job it looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mutation-angular:
  name: Mutation Testing (Angular / Stryker)
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v7
    - uses: actions/setup-node@v6
      with:
        node-version: 22
        cache: 'npm'
        cache-dependency-path: crud-angular/package-lock.json
    - name: Install dependencies
      run: npm ci
      working-directory: crud-angular
    - name: Run Stryker
      run: npm run test:mutation
      working-directory: crud-angular

The thresholds.break value in the config does the gating: if the mutation score drops below 50, stryker run exits non-zero, the job fails, and the pull request turns red, the same mechanism as every other check in the build. Upload reports/mutation/mutation.html as an artifact and you get a browsable report on every run, with each survivor linked to its line.

Keeping Mutation Testing Practical

Mutation testing earns a reputation for being slow and noisy, and both problems are real if you turn it on naively. A few practices keep it in the pipeline instead of getting it disabled:

Do not chase 100%. A mutation score in the 70-85% range on meaningful code is excellent; the last stretch is dominated by equivalent mutants and diminishing returns. The break threshold of 50 in this config is a floor that catches genuinely weak suites, not a target to max out. Set the floor where a regression would actually be dangerous, and ratchet it up as the suite strengthens, the same graduated approach the architecture testing post used for its rules.

Learn to recognize equivalent mutants. Sometimes a mutant genuinely cannot be killed because it produces no observable difference: the redundant case 'front-end' above is close to one. These are not test gaps; they are usually a signal to simplify the code. Do not contort a test to kill an equivalent mutant. Delete the redundancy or document why it is equivalent, and move on.

Scope the run. Stryker supports incremental mode and --since to mutate only changed files, which keeps pull-request runs fast while a scheduled full run guards the whole codebase. Exclude generated code and thin glue from mutate: mutating a barrel file teaches you nothing.

Fix the code, not just the test. The most valuable survivors, like the redundant branch, are telling you something about the production code, not only the tests. Half the payoff of mutation testing is the dead branches and unguarded paths it surfaces along the way.

Conclusion

A coverage report tells you which lines your tests executed. It cannot tell you whether a single assertion would have failed if those lines misbehaved, which is why a frontend can show 100% coverage and still ship a component whose tests would wave through any regression. Mutation testing closes that gap by breaking your code on purpose and counting how many sabotaged versions your tests actually catch. A surviving mutant is not a metric; it is a specific, located change to your production code that no test would notice, and often a piece of dead or unguarded code you did not know was there.

A reasonable adoption path:

  1. Install Stryker and start with the command runner wrapping ng test. It is the most robust setup while Angular’s test runner keeps evolving.
  2. Run it once on a small, logic-heavy target (a pipe, a service, a guard) and read the survivors before you touch anything else. Let the tool show you what your green suite was missing.
  3. Kill the survivors that represent real gaps by asserting on specific behavior. Delete or document the ones that are equivalent.
  4. Set a break threshold at a floor that would embarrass a genuinely weak suite, and wire stryker run into CI as a gating job.
  5. Move to the dedicated Vitest runner with coverageAnalysis: "perTest" when the full-suite-per-mutant cost starts to hurt.
  6. Run it incrementally on pull requests and fully on a schedule. Ratchet the floor up as the suite strengthens.

Where PIT asked this question of your Java backend, Stryker asks it of your Angular frontend: not “did the code run under test,” but “would a test have noticed if the code were wrong?” It is the same behavior sensor, now covering the half of the stack where toBeTruthy() has done the most quiet damage. In a world where an agent can generate a component and a coverage-green test suite in the same prompt, the mutation score is the difference between “the tests pass” and “the tests would catch a bug.”

References

Happy Coding!

This post is licensed under CC BY 4.0 by the author.
This site uses cookies. Please choose whether to accept analytics cookies. Privacy Policy