DEV Community

Ebrahim Sayed Ebrahim
Ebrahim Sayed Ebrahim

Posted on

You inherited a .NET codebase with zero tests. Now what?

Every .NET developer has had that moment.

You join a new team, clone the repo, open the solution, and see 200+ files. You check the test project — it's either empty, missing, or has three tests from 2019 that no longer compile. Your manager asks in the standup: "Can you add some tests to improve our confidence before we ship?"

Sure. But where do you start?

The two wrong answers

Most people default to one of two strategies:

Strategy A: Start from the top. Open the first file alphabetically, write tests. Move to the next. This is satisfying in the same way cleaning your desk is satisfying — it feels like progress but doesn't address the actual risk. You end up testing AccountHelper.cs (which hasn't been touched in two years) while PaymentProcessor.cs (37 commits this quarter, zero coverage) quietly ships bugs to production.

Strategy B: Test whatever you touched last. This is reactive. You fix a bug, write a test for that bug, move on. Better than nothing, but it's not a strategy — it's a reflex. You never get ahead of the problem.

There's a third option that Roy Osherove describes in The Art of Unit Testing: build a priority list. Rank your files by how much risk they carry and how practical they are to test. Then work through that list from the top. The idea is sound. The problem is that Osherove describes it as a manual exercise. In a codebase with 200 files, nobody is going to sit down with a spreadsheet and score each one by hand.

So I automated it.

What Litmus does

Litmus is a .NET CLI tool. You install it, run one command, and get a ranked list of files — sorted by where you should start testing today.

dotnet tool install --global dotnet-litmus
dotnet-litmus scan
Enter fullscreen mode Exit fullscreen mode

That's the whole setup. No server, no dashboard, no config file. It finds your solution, runs your tests, collects coverage, analyzes your git history and source code, and produces output like this:

── Act Now ──────────────────────────────────────────────────────────────────────────────────
Rank  File                      Commits  Coverage  Complexity  Coupling   Risk    Priority
1     OrderService.cs           47       12%       94          Low        High    High
2     ReportFormatter.cs        22       31%       67          Low        High    High

── Next Sprint ──────────────────────────────────────────────────────────────────────────────
3     PaymentGateway.cs         31       8%        118         Very High  High    Medium

── Monitor ──────────────────────────────────────────────────────────────────────────────────
4     LegacyDbSync.cs           41       0%        201         Very High  High    Low
Enter fullscreen mode Exit fullscreen mode

Look at PaymentGateway.cs. It has higher complexity and lower coverage than OrderService.cs. Intuitively, you'd think it should be #1. But Litmus ranked it lower. Why?

Because it's too entangled to test right now.

The insight that existing tools miss

Here's the thing that coverage tools, SonarQube, and most static analysis platforms don't tell you: whether you can actually write a test for a given file today, without refactoring it first.

A file that calls DateTime.Now in five methods, instantiates HttpClient directly, and takes a concrete SqlConnection in its constructor has no seams. You can't substitute anything. You can't isolate it. If you sit down to write a unit test for it, you'll spend your afternoon fighting infrastructure dependencies instead of actually testing logic.

Michael Feathers called these "seams" in Working Effectively with Legacy Code — points where you can swap a dependency in a test without touching production code. The concept has been standard vocabulary in our industry for twenty years. But I couldn't find a single publicly available tool that detects them automatically.

So Litmus does.

It uses Roslyn to scan your source code and detect six categories of unseamed dependencies:

  • Infrastructure callsDateTime.Now, File.ReadAllText(), Environment.GetEnvironmentVariable(), raw new HttpClient() or new SqlConnection()
  • Direct instantiation in methodsnew ConcreteService() inside a method body, where the test has no way to substitute it
  • Concrete constructor parameters — constructor params that take OrderService instead of IOrderService
  • Static callsSomeHelper.Calculate() with no instance to substitute
  • Async I/O seam callsawait _httpClient.GetAsync(), await _db.SaveChangesAsync()
  • Concrete downcasts(ConcreteType)expr that defeats your interface abstractions

Each signal has a different weight. Infrastructure calls (weight 2.0×) are worse than concrete constructor params (weight 0.5×), because there's literally no substitution point for DateTime.Now — at least a constructor parameter gives you a structural seam to work with.

Two phases, one formula

Litmus runs in two phases:

Phase 1 — Risk: How dangerous is this file?

RiskScore = Churn × (1 - Coverage) × (1 + Complexity)
Enter fullscreen mode Exit fullscreen mode

Files that change often, lack test coverage, and have complex branching logic get a high risk score. Files that nobody touches (zero churn) score zero regardless of complexity — if nobody's changing it, nobody's breaking it.

Phase 2 — Starting Priority: Can you do something about it today?

StartingPriority = RiskScore × (1 - Coupling)
Enter fullscreen mode Exit fullscreen mode

This is where Litmus diverges from every other tool I've seen. It takes that risk score and discounts it based on how entangled the file is. A fully decoupled file keeps its entire score. A maximally entangled file drops to zero priority — not because it's safe, but because you'd be wasting your sprint trying to test it before introducing seams.

The result is three buckets:

  • Act Now — high risk, low coupling. Write tests today. These are your biggest wins.
  • Next Sprint — high risk, high coupling. Plan seam introduction, then test.
  • Monitor — lower risk or too entangled to address yet. Keep an eye on it.

No tests yet? That's the point.

I should mention: Litmus is designed for codebases that don't have tests. That's the whole use case. If you're starting from zero, run:

dotnet-litmus scan --no-coverage
Enter fullscreen mode Exit fullscreen mode

This skips the test run entirely and ranks files by churn, complexity, and coupling alone. You still get the Act Now / Next Sprint / Monitor grouping. You still get a prioritized list of where to start.

Going deeper

Once you know which files to focus on, you can drill into methods:

dotnet-litmus scan --detailed
Enter fullscreen mode Exit fullscreen mode
1  OrderService.cs   47   12%   94   Low   High  High
     ProcessOrder     —    50%   25
     ValidateInput    —    0%    18
Enter fullscreen mode Exit fullscreen mode

Now you know: start with ValidateInput. It has zero coverage and non-trivial complexity. ProcessOrder is partially covered already.

You can also track progress over time:

dotnet-litmus scan --output baseline.json
# ... a few sprints later ...
dotnet-litmus scan --baseline baseline.json
Enter fullscreen mode Exit fullscreen mode

A Delta column appears showing what improved, what degraded, and what's new. You can bring this to sprint retros and show concrete progress.

CI integration

Litmus fits naturally into a pipeline. Here's the minimal GitHub Actions setup:

- uses: actions/checkout@v4
  with:
    fetch-depth: 0  # Litmus needs full git history

- run: dotnet tool install --global dotnet-litmus

- run: dotnet-litmus scan --output report.json --quiet

- run: dotnet-litmus scan --fail-on-threshold 1.0 --quiet
Enter fullscreen mode Exit fullscreen mode

That last line is the quality gate. If any file's risk score exceeds 1.0, the build fails. You can tune the threshold as your codebase improves.

How is this different from SonarQube?

I get this question a lot. Short answer: they solve different problems.

SonarQube is a broad code quality monitoring platform. It tracks hundreds of rules, produces dashboards, and works well for ongoing governance. It requires a server, and full analysis is a paid feature.

Litmus answers one specific question: "I inherited this codebase — where do I start testing?" It installs in 10 seconds, runs from the terminal, and gives you a ranked list. No server, no license, no dashboard.

They complement each other. Run SonarQube for ongoing quality gates. Run Litmus when you need to decide where to invest testing effort next sprint.

Why I built this

I work mostly on a legacy .NET codebase with not so many tests. I kept finding myself doing the same exercise at the start of every engagement — manually scanning git logs, cross-referencing with coverage reports, eyeballing the source for testability, and trying to figure out where to start.

It took me half a day each time, and I was never confident I'd gotten it right. So I turned it into a tool.

The scoring model is transparent and formula-driven. Every score is reproducible. You can look at a file's individual signal values with --verbose and understand exactly why it ranked where it did. That explainability matters — when you show up at sprint planning and say "we should test OrderService.cs first," having a formula backed by git data and coverage numbers is more persuasive than "I looked at the code and I think this one's important."

Try it

dotnet tool install --global dotnet-litmus
dotnet-litmus scan
Enter fullscreen mode Exit fullscreen mode

Or if you don't have tests yet:

dotnet-litmus scan --no-coverage
Enter fullscreen mode Exit fullscreen mode

It works with .NET 8, 9, and 10. MIT licensed. The full source is on GitHub.

If you find it useful, I'd love a star on the repo. If you find a bug or have a suggestion, open an issue — I read every one of them. And if you've got a story about inheriting a legacy codebase and figuring out where to start, I'd genuinely like to hear it.


Links:

Top comments (0)