Modernising and cross-platforming a .NET Windows desktop app - Part 2
- 13 minutes read - 2588 wordsIn this blog I continue my journey of modernising and cross-platforming a .NET Framework Windows desktop application.
- Part 1: Why you might need to modernise and cross-platform an existing desktop application and how you can do the simplest (but wrong) thing.
- Part 2 (this post): Using my guinea-pig application, create a safety net of tests around the code before refactoring (where necessary) and moving from .NET Framework to .NET (Core).
- Part 3: Continuing the journey, I’ll replace the WinForms part of my guinea-pig application with a true cross-platform UI framework.
In part 1 I looked at why you might need to do this in the first place and how doing the simplest thing works (kind of), but probably isn’t setting you up for success in the longer term.
In this blog I will put tests in place around the existing code, so I can safely and incrementally change and modernise the code as I want to without fear of breaking things. The end result will be the application working exactly as it did before but running on the latest .NET stack and in a much better position to support future growth and change.
What am I aiming for?
I want to make this as realistic and comprehensive as possible so I’m setting the following aims for myself in this modernisation part of the project:
- make small, safe, incremental changes without breaking anything
- good test coverage
- automated tests that run locally and in a build pipeline
- make use of latest language features
- refactor and improve the code where appropriate
- apply and enforce consistent code style
- end up with a
net8
application running on Windows (cross-platforming comes in part 3)
What’s my starting point?
The TimeInWords solution currently targets net481
. It has 5 projects in all and the structure and responsibility of each
is pretty well-defined already (which is a nice start; lots of codebases start in a much worse state than this):
TimeToTextLib
: a class library that takes aDateTime
and returns a textual representation of the timeTextToTimeGridLib
: a class library that takes astring
and generates a bitmask grid against predefined grid of letters, thus spelling out the input on the grid (if possible)TimeInWordsScreensaver
: the WinForms application that uses the above projects to display a full-screen letter grid spelling out the timeConsoleDebugApp
: a console application used for manual testingDebugApp
: a WPF application used for manual testing
As you may have guessed, there are no unit tests in the solution 😭. The basis of the solution (TimeToTextLib
and TextToTimeGridLib
) were created by another developer and we can surmise that the “debug apps” were used in their place.
Automated tests? Yes please!
First things first, I don’t want to change anything without a safety net of tests. Once that is in place I can refactor, change, delete and upgrade without fear as long as my tests stay green. And if I do break anything I get to know ASAP.
In .NET, as unit test projects are the top-level assembly that is executed by any test runner you might choose to use,
my new test projects can be net8
from the start. My “go-to” .NET testing packages are:
- xUnit: unit testing framework and test runner
- FluentAssertions: provides a powerful and more natural way to specify test assertions
- coverlet: code coverage framework
- NSubstitute: mocking library
Starting with the lowest project in the dependency tree, TimeToTextLib
, I create a corresponding test project using
the above packages, reference the TimeToTextLib
project and spot my first warning:
Microsoft.Common.CurrentVersion.targets(1879, 5):
[NU1702] ProjectReference 'D:\src\TimeInWords\src\TimeToTextLib\TimeToTextLib.csproj' was resolved using
'.NETFramework,Version=v4.8.1' instead of the project target framework '.NETCoreApp,Version=v8.0'.
This project may not be fully compatible with your project.
I had anticipated this and expected to use .NET Standard.
.NET Standard is a formal specification of the APIs that fall into the intersection of the different .NET implementations.
It, itself, has different versions, fully backward compatible, with later versions including more and more APIs. The different
.NET implementations, at different versions, support different versions of .NET Standard. What this means is that you can freely
target a particular .NET Standard version and have confidence that it will work with all the
listed .NET implementations that support that version.
Thus, .NET Standard allows you to share code across .NET Framework and other compatible .NET implementations.
But what surprised me here was that doing it without .NET Standard compiled at all! And the phrase ...may not be fully compatible...
suggests it might work to some degree! After all, whilst .NET Framework and .NET are related in many ways, they each have
their own runtime and you can’t mix-and-match. However, it turns out that there is also a
compatibility mode
that allows referencing .NET Framework projects from .NET projects but, as you can read from the link, here be dragons!
So I went for the safe option and changed the TimeToTextLib
project to target netstandard2.0
as net481
and net8
both
support netstandard2.0
.
Note: if there isn’t a .NET Standard version that you can use for your different .NET versions AND that includes the APIs you need, then you can multi-target your project(s) instead and use preprocessor symbols around the problematic APIs. But using .NET Standard is far easier if you can.
What tests do I need?
The aim of my tests is to ensure I don’t break anything or introduce any change in behaviour as part of the upgrade/refactoring/improvement process. As such, I want to create tests that assert the existing behaviour of the code. Fortunately there is a testing technique called Golden Master that fits this scenario very nicely. Essentially:
- record a set of real-world inputs
- pass those through the production code
- record the corresponding outputs
- create tests that assert each input to each output
With this in place, as long as those tests stay green, you haven’t broken anything. For TimeToTextLib
I was able to do
just that and generate test data:
[Fact(Skip = "test code generator")]
public void GenerateTheoryData()
{
var result = new StringBuilder();
var time = new DateTime(2024, 1, 1, 0, 0, 0);
while (time.Hour < 13)
{
var timeAsText = _preset.Format(time);
result.AppendLine(
CultureInfo.InvariantCulture,
$"Add(new DateTime(2024, 1, 1, {time.Hour}, {time.Minute}, 0), \"{timeAsText}\");"
);
time = time.AddMinutes(1);
}
testOutputHelper.WriteLine(result.ToString());
}
(I can run it as required as a test itself, for convenience and locality to where the output is used)
This generates tests for every minute on a 12-hour clock:
private class FormatTimeToTextCorrectlyTheoryData : TheoryData<DateTime, string>
{
public FormatTimeToTextCorrectlyTheoryData()
{
Add(new DateTime(2024, 1, 1, 0, 0, 0), "IT IS TWELVE OCLOCK +0");
Add(new DateTime(2024, 1, 1, 0, 1, 0), "IT IS TWELVE OCLOCK +1");
Add(new DateTime(2024, 1, 1, 0, 2, 0), "IT IS TWELVE OCLOCK +2");
Add(new DateTime(2024, 1, 1, 0, 3, 0), "IT IS TWELVE OCLOCK +3");
Add(new DateTime(2024, 1, 1, 0, 4, 0), "IT IS TWELVE OCLOCK +4");
Add(new DateTime(2024, 1, 1, 0, 5, 0), "IT IS FIVE PAST TWELVE +0");
Add(new DateTime(2024, 1, 1, 0, 6, 0), "IT IS FIVE PAST TWELVE +1");
...
}
}
The test itself is then very simple:
[Theory]
[ClassData(typeof(FormatTimeToTextCorrectlyTheoryData))]
public void FormatTimeToTextCorrectly(DateTime time, string expected) =>
_preset.Format(time).ToString().Should().BeEquivalentTo(expected);
This works brilliantly for TimeToTextLib
as the range of inputs is bounded (to the minutes on a 12-hour clock), so
complete coverage. But for TestToTimeGridLib
the range of inputs is (arguably) unbounded, as it can take any string
and
try to print that out on the letter grid. However, the same technique can be used as long as you have confidence that the
range of inputs you have recorded covers your use cases. In my scenario I could narrow the range of inputs to the output of TimeToTextLib
,
e.g. IT IS TWELVE OCLOCK
, IT IS FIVE PAST TWELVE
, etc, etc.
Lastly on the testing topic, how do you know you have enough tests? This is where coverlet comes in. By adding its NuGet package to your test projects you allow it to introspect your test and production code and work out which bits of code are covered by your tests and which aren’t. It can also produce a nice interactive report with the coverage stats and CRAP and cyclometric complexity scores.
Here is the report for those two projects:
Now, test coverage stats aren’t a guarantee of test quality, but they at least give an indication of where the gaps are and therefore where you should focus your efforts.
Ready, set, modernise!
With my tests in place I am free to upgrade, refactor, improve these class projects as needed. As I listed in
my aims at the start of this post, in addition to targeting net8
, I also want to:
- make use of latest language features
- apply and enforce consistent code style
- refactor and improve the code where appropriate
Use the latest language features
My IDE of choice (Jetbrains Rider) is extremely helpful and suggests all sorts of goodies and automatic code fixers
to use based on what .NET version your projects are targeting. For example, primary constructors,
collection expressions,
sensible use of var
,
raw string literals … the list goes on.
Visual Studio does a similar thing and (I found) does an even better job with Resharper installed, essentially gaining the same suggestions/fixers as Rider.
Apply and enforce consistent code style
The TimeInWords solution is just a smaller scale version of any other real-world codebase, i.e. different developers have
contributed over a long time. Without any controls in place this will quickly lead to differing coding styles being used,
making the codebase messy and inconsistent. To remedy this we can take advantage of the IDE leveraging an
.editorconfig
file and tools such as csharpier and dotnet format
. I’m not going to delve into
this further here but using tools such as these helps keep your codebase clean, consistent, easier to read and easier to maintain.
Refactor and improve the code where appropriate
Again, with the safety net of tests in place, take the opportunity to improve the code as you go. As ever, you need to pick and choose how far down the rabbit hole you go, as what refactorings are “appropriate” will depend entirely on your codebase, it’s current state, it’s complexity and the time available. But to call out one example in TimeInWords, I was able to push duplicated logic in derived classes down into the base class. All the little wins will soon add up!
What about the WinForms project?
Adding tests to a class library project is often easier than a project that uses some kind of framework, especially if that framework wraps something else like a graphical user interface and was not designed with testability in mind.
With UI frameworks like WinForms and WPF, it is very easy to fall into the trap of putting your business logic in the “code-behind” of the Forms or Controls, i.e. the event handlers. Using these event handlers is necessary to effectively use the framework but putting your logic directly in those event handler tightly binds your code with the framework code and leaves your logic untestable.
Unfortunately, this is issue with the TimeInWords WinForms app.
So how can we create the tests we need before refactoring and upgrading? With difficulty. One approach would be to use a UI automation framework, i.e. a framework that can be used to automate interacting with your UI, clicking buttons, testing that UI elements are visible, etc. These sorts of tools are much more prevalent with web apps, e.g. Selenium, Playwright, Puppeteer. In the .NET desktop world there are also similar tools such as Appium, TestComplete and Microsoft’s WinAppDriver. I should note here that UI tests are far from ideal. They involve more moving parts, are often difficult to set up, tricky to make work reliably across local developer environments and in a CI/CD pipelines and can be brittle if the UI changes often. This is why they sit at the top of the testing pyramid.
I would always recommend having a suite of automated tests in place before starting a significant refactoring exercise but in the case of TimeInWords, which has very little UI interaction, I’m going to use manual testing. However, as I work through pushing my business logic away from the UI framework, I can (and will now be able to) add automated tests as I go. These tests will be invaluable in part 3 as I swap WinForms out for a different UI framework as my business logic will now be cleanly separated. So my manual testing will only be required for the UI interaction with the application; good portions of the logic that sits behind the UI itself will have automated tests.
Separating the logic from the UI framework
There are a number of GUI architectures but most derive from MVC pattern that has been around for decades. Essentially, the goal is to manage the complexity of a GUI application by separating its presentation from its business logic - exactly what I want to do.
For TimeInWords I choose to use a simple passive view MVP approach, where the view is as dumb as possible and all the logic is contained in the presenter. I used this repo as a simple sample to get me started and applied the pattern to my two views. It essentially breaks down like this:
- extract an interface for your view (i.e. a WinForms
Form
) and make your view implement it - create a presenter class that depends on that interface (it must not depend on the view itself)
- driven by new tests, push logic that does not interact with UI components from the view into the presenter, creating properties and method on the view interface where necessary
- logic that does directly touch UI components stays in the view
For example, here is the TimeInWords view interface:
public interface ITimeInWordsView
{
public DateTime Time { get; set; }
public TimeToTextFormat TimeAsText { get; set; }
public bool[][] GridBitMask { get; set; }
void Initialise(TimeInWordsSettings settings, TimeGrid grid);
void Update(bool force = false);
}
It has properties that the presenter sets data on and methods it can call to interact with the view. The presenter itself depends on:
public TimeInWordsPresenter(
ITimeInWordsView view,
TimeInWordsSettings settings,
IDateTimeProvider dateTimeProvider,
ITimer timer
) { ... }
i.e. the view abstraction, settings and abstractions for getting the current time and a timer (which aid unit testing).
Summary
So, I covered quite a lot in this post:
- the importance of creating a safety net of tests before starting significant changes to your code
- using the Golden Master technique to create those tests to ensure the behaviour of your code doesn’t change as you refactor/upgrade
- using .NET Standard or multi-targeting to share code between your .NET Framework and .NET code
- using coverlet to highlight where your test coverage may be lacking
- using the power of your IDE to refactor code safely and enforce code style
- the importance of separating business logic from your UI logic and how you can do that using the MVP pattern
What state is my solution in now? Well, I have:
- the original
net481
WinForms project - a new, side-by-side version of that project targeting
net8
, still using WinForms 😭 but employing the MVP pattern to separate business logic and UI logic with good test coverage as a result - class library projects with excellent test coverage, targeting .NET Standard and shared between the two projects above
If you want to browse around the code at this point in its journey then you can do so here. Obviously, it’s still a work-in-progress 😉.
In part 3 I’ll investigate alternatives for WinForms and get my app to run truly cross-platform…