Characterization Tests Around Shared View Models, Before You Touch the UI

The question that decides whether a migration goes well is asked before any code is moved: how will you know the migrated app behaves the same as the one in production?

The usual answer is a test plan and a QA pass. That catches the obvious differences. It does not catch the rounding rule in a total, the specific ordering of a list, or the validation that rejects a particular input format — which are exactly the behaviours that a field team has built habits around and will report as outages.

Characterization tests catch those, and the time to write them is before the UI layer is touched.

What a characterization test is

A characterization test asserts what the code does, not what it should do. You run the existing behaviour, observe the output, and write that output into the test as the expected value — including the parts that look wrong.

That last clause is the one people resist, so it is worth being explicit. If a view model rounds a currency total half-down where the specification says half-up, you pin half-down. The purpose of the test is not correctness. It is to make change visible. If the behaviour is wrong, that is a separate ticket, fixed deliberately, in its own release, with the test updated as part of the fix — not silently corrected during a migration where it will be indis tinguishable from a port bug.

Why view models are the right target

In a Xamarin.Forms app with any MVVM discipline, the shared project holds the part of the codebase that represents real investment: view models, services, models, validation, formatting. That code moves to .NET MAUI largely intact — it is the UI layer and the project structure that change.

This is what makes the migration testable in a way that many modernisations are not. You can pin the behaviour of the part that is not changing, then change everything around it and have the tests still run. Shared logic tests do not need a device, a simulator, or a UI automation harness; they are plain unit tests against plain classes.

Start from usage, not from coverage

Do not start at the top of the solution explorer. Start from what the app is actually used for, which you can get from three places: the analytics, the crash reports, and twenty minutes with whoever supports the app.

Pin, in this order:

  1. Anything involving money or quantity. Totals, taxes, discounts, conversions, rounding. These are the differences people notice and escalate.
  2. Anything involving dates and time zones. Especially anything comparing a device-local date to a server date. Migration is a good time to discover you have a bug here, and a terrible time to introduce one.
  3. Validation and parsing. What the app accepts and rejects, in its exact current form. Field apps accumulate very specific input habits.
  4. Ordering and filtering. The order a list comes out in is behaviour, even when nobody wrote it down.
  5. State machines. Multi-step flows, offline queueing, retry and sync logic. Pin the transitions, including the odd ones.

Dealing with code that resists testing

A view model that news up an HttpClient and calls a static platform helper is not testable as written, and you have to resolve that before you can pin it. There are three moves, in order of preference:

Inject the seam. If the dependency is already behind an interface, or can be with a small refactor, do that — but make the refactor its own commit, with no behaviour change, so the diff is reviewable as "moved a dependency" rather than mixed into the test.

Wrap the static. Platform APIs reached through static helpers can be put behind a thin interface with a default implementation that calls the static. The production path is unchanged and the test path is substitutable.

Pin at a higher level. When a class genuinely cannot be untangled cheaply, test the layer above it against a fake service and accept coarser granularity. A coarse test that exists beats a fine one that you did not write.

What to avoid is a large refactor to make the code testable before you have any tests. That is the sequence that introduces the bug you were trying to prevent.

Golden-file tests for output

Where a view model produces a substantial structured result — a report, a summary, a serialised payload — a golden-file test is faster to write than a hundred assertions. Serialise the result deterministically, commit the output, and assert equality. When behaviour changes, the diff shows you exactly what changed, which is far more useful than a failed assertion on one field.

Two rules make these reliable: serialise with stable ordering, and inject the clock. A golden file containing DateTime.Now fails tomorrow and teaches the team to ignore failures.

Knowing when to stop

You are not aiming for a coverage number. You are aiming to be able to say, of each business rule that matters, that a change to it would fail a test.

A practical stopping point: every flow named in the migration plan has its shared logic pinned, and every money, date and validation rule in those flows has at least one test. On a typical line-of-business app that is one to two weeks of work, and it is the fortnight that pays for itself most reliably.

What it buys you

During the migration, the tests run on every slice. When a handler conversion changes a binding path, or a replaced dependency formats a number differently, CI says so in minutes rather than a user saying so in a support ticket.

Afterwards, the tests stay. They are the documentation of what the app does that nobody ever wrote, and they are what makes the next OS upgrade a routine piece of work rather than a leap.

This is why pinning behaviour is the first step of the migration rather than a quality initiative alongside it — and why an assessment reports current test coverage honestly rather than as a footnote.