Every time I changed code, I’d Alt-Tab to the terminal, arrow up, Enter. dotnet test. Wait. Check. Back to the editor. Repeat.
It’s not the end of the world. But over a full day, it adds up, mostly in lost focus. The annoying part is that I already had dotnet watch in my workflow for hot-reloading a web app. I just never thought to ask what else it could do.
Spoiler: a lot.
dotnet watch test
The most underrated command in the .NET CLI:
dotnet watch test
That’s it. Every time a .cs file changes in the project, your tests re-run automatically. No VS Code extension, no plugin, no NCrunch. Just the SDK.
It supports --filter too, so you can target a subset of tests:
dotnet watch test --filter "FullyQualifiedName~MyNamespace.MyClass"
Or by trait:
dotnet watch test --filter "Category=Unit"
While you code, only unit tests run. Integration tests stay for CI or a manual run before you commit.
Watching non-.cs files
By default, dotnet watch monitors project files: .cs, .razor, .cshtml, etc. But sometimes you need it to pick up other changes. A JSON config file, .sql files, templates.
You can add extra watch items in your .csproj:
<ItemGroup>
<Watch Include="**\*.json" Exclude="bin\**;obj\**" />
<Watch Include="TestData\**\*" />
</ItemGroup>
Now if I edit a test data file in TestData/, tests re-run. Handy when your tests read fixtures from disk.
A concrete example
Here’s a setup I use regularly. An xUnit project with traits to separate unit tests from integration tests.
The trait is a simple attribute:
public class CategoryAttribute : TraitAttribute
{
public CategoryAttribute(string category)
: base("Category", category) { }
}
Note:
CategoryAttributeis used as[Category(...)]because C# lets you drop theAttributesuffix. If you prefer to keep things simple, just use[Trait("Category", "Unit")]directly on your tests.
A unit test:
[Fact]
[Category("Unit")]
public void ParseConfig_WithValidJson_ReturnsExpected()
{
var json = """{"retries": 3, "timeout": 30}""";
var config = ConfigParser.Parse(json);
Assert.Equal(3, config.Retries);
Assert.Equal(30, config.Timeout);
}
An integration test:
[Fact]
[Category("Integration")]
public void GetUser_FromDatabase_ReturnsValidUser()
{
// ... DB access, slower
}
And the command running in the background while I code:
dotnet watch test --project tests/MyProject.Tests \
--filter "Category=Unit"
I save a file, unit tests pass in 2 seconds. Near-instant feedback.
Bonus: split terminal
Here’s what I do most days: split my terminal in two.
- Left pane:
dotnet watch test --filter "Category=Unit", tests running continuously. - Right pane:
dotnet watch run, the app reloading on every change.
In VS Code, Ctrl+\ splits the terminal. Windows Terminal and iTerm2 support it natively too.
The result: I save a file, and my tests run while the app reloads. At the same time. Zero friction.
┌──────────────────────────┬──────────────────────┐
│ $ dotnet watch test │ $ dotnet watch run │
│ --filter Category=Unit │ │
│ │ │
│ Tests passed: 14 │ Now listening on: │
│ Duration: 1.8s │ https://localhost:5001│
│ Waiting for changes… │ Waiting for changes… │
└──────────────────────────┴──────────────────────┘
One last thing
If dotnet watch detects a change that can’t be hot-reloaded (a method signature change, for example), it rebuilds automatically. No need to restart it.
And if you want to force a full rebuild on every change instead of hot reload:
dotnet watch test --no-hot-reload
This has made my life easier than I expected. If you’re still doing Alt-Tab, arrow up, Enter, try it for a day. You won’t want to go back.
Happy watching (and let go of the up arrow).
This post was written with the help of AI.