A pleasant walk through computing

Comment for me? Send an email. I might even update the post!

.NET MAUI Progressing From a Default Project Part 2 - (The Problem With) Basic Unit Testing

The Series

Level: Moderate

This series assumes the developer is familiar with .NET MAUI, the Model-View-ViewModel pattern, and has worked through one or more tutorials such as the excellent James Montemagno's Workshop.

Source: https://github.com/bladewolf55/net-maui-progression

.NET MAUI's testing problem

At the time of this writing, .NET MAUI apps don't support unit testing. At least, not in any way I could find. And I tried, I really tried, to fool .NET by playing with target frameworks and conditionally including a Program Main method, and other goofy stuff, but to no avail.

So, that's the bad news, a real bummer, and in my opinion a terrible oversight on the .NET MAUI team's part. There's no talk of unit testing, no guidance, no explanation.

Here's the good news: you can unit test .NET MAUI class libraries. Examples can be found at the Community Toolkit repository (thank you, open source!).

This means we need to separate anything we want to unit test into a library. That's not ideal, especially for smaller projects, but we'll make do. I have confidence Microsoft is working on a better way.

Add the ViewModels library

To keep things organized, let's add a ViewModels library. Later, we'll add a Models library.

You could also have a single library for testable code, broken into namespaces. I would do that here, but, frankly, I wasn't sure what to name it! Plus, separate libraries reenforces our separation of concerns.

  1. Solution > Add > New Project > .NET MAUI Class Library named "Maui.Progression.ViewModels".
  2. Install the Microsoft.Toolkit.Mvvm package, and uninstall that package from the app project.
  3. Delete the Class1.cs file.
  4. Copy or move the Counter.cs file to the root of the ViewModels library.
  5. In the app project, delete the ViewModels folder.
  6. In the app project, add the ViewModels project as a project reference.
  7. Update the MainPage.xaml viewmodels namespace.
    xmlns:viewmodels="clr-namespace:Maui.Progression.ViewModels;assembly=Maui.Progression.ViewModels"
    

At this point you may need to do a Solution Clean and Rebuild, or even close Visual Studio, delete the bin/obj folders, and restart then build.

For example, I got this warning:

Warning	WMC1006	Cannot resolve Assembly or Windows Metadata file 'C:\Users\charl\source\repos\dotnet-maui-progression\src\Maui.Progression.02\Maui.Progression.ViewModels\bin\Debug\net6.0-windows10.0.19041.0\Maui.Progression.ViewModels.dll'	Maui.Progression	C:\Users\charl\source\repos\dotnet-maui-progression\src\Maui.Progression.02\Maui.Progression\Maui.Progression.csproj

Running the app should behave as before.

Add the testing project

My preferred unit testing stack is xUnit, FluentAssertions, and NSubstitute. I'm keeping this tutorial simple by using vanilla xUnit.

  1. Solution > Add > New Project > xUnit Test Project named "Maui.Progression.UnitTests"
  2. Add Maui.Progression.ViewModels as a project reference.
  3. Rename UnitTest1.cs to Counter_Should.cs

    This is just my preferred naming convention.

  4. Update Counter_Should.cs with this code.
    using Maui.Progression.ViewModels;
    
    namespace Maui.Progression.UnitTests;
    
    public class Counter_Should
    {
        [Fact]
        public void Increment_counter_by_one_using_command()
        {
            // arrange
            int expected = 1;
            var vm = new Counter();
    
            // verify the starting count
            Assert.True(vm.Count == 0, "Starting count should have been zero.");
    
            // act
            vm.IncreaseCounterCommand.Execute(null);
    
            // assert
            Assert.Equal(expected, vm.Count);
        }
    }
    

Run the test, which should pass.

As a final check, rerun the app.

Wrap Up

We've separated out our View Model into a testable library, and written a simple passing test. We haven't written any kind of UI test, but that's OK for now. In a future part, I hope to try out the Visual Studio App Center, which appears purpose-built for multi-platform UI testing.

Next up: adding a Model!

Resources

.NET MAUI Progressing From a Default Project Part 1 - Adding the View Model

The Series

Level: Moderate

This series assumes the developer is familiar with .NET MAUI, the Model-View-ViewModel pattern, and has worked through one or more tutorials such as the excellent James Montemagno's Workshop.

Source: https://github.com/bladewolf55/net-maui-progression

The Problem .NET MAUI Solves

Cross-platform development is hard, usually requiring maintaining a project per platform. Any feature must be added to each code base. .NET MAUI uses a single project with the majority of the UI code using XAML, and the business code in C#. This code is then transpiled to the native platforms. The result is less maintenance, fewer errors, and reduced knowledge needed to build cross-platform applications.

(Brief) Getting Started: The Default App

These instructions assume running in Windows 10/11. You can follow the instructions below, which are brief and aren't intended as a beginner's guide. Or, better yet, use Microsoft's official documents.

The Project

  1. Install and open Visual Studio 2022 Preview
  2. Create a new project
  3. Filter for C# MAUI, or search for ".NET MAUI App"
  4. Create a .NET MAUI App using the defaults. Change the name to match mine if you want.

The Android Emulator

Important
You'll need to enable either Hyper-V or HAXM.

Note
I had trouble with Hyper-V the first time I tried .NET MAUI on my laptop even though it's supported, and had to use HAXM for a while. The problem went away when I did a clean install of Windows 11. Presumably, that would have been true in Windows 10.

  1. Choose Debug Target > Android Emulator
  2. Click to start the emulator. The Android Device Manager starts. Answer Yes to allow changes.
  3. You can accept the default Pixel 5 Android 12.0 (Level 31). However, I recommend canceling at this point and manually creating a Pixel 5 Android 12.1 (Level 32) or later. There are some behavior differences worth seeing, specifically in how the splash screen is treated.
  4. You don't need to explicitly start the emulator. Running the app will do that. So . . .
  5. Run the app!

This should start the emulator, then install and run the default app. On my machine, this takes a couple of minutes.

Clicking the button increases a counter.

Warning
As of 2022-06-20, using Android 12.1 as configured above, clicking the button doesn't display the number of clicks. I'm not sure what in the style sheets is preventing this, but you can work around it this way:

  1. Open Resources > Styles > Styles.xslt
  2. Add "MinimumWidthRequest". This forces a wide button.
<Style TargetType="Button">
    <Setter Property="MinimumWidthRequest" Value="200" />
    <Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Primary}}" />

MVVM Basics

Great, we have a working app! And the .NET MAUI team have included lots of boilerplate to understand. But one thing not included is implementing the Model-View-ViewModel "MVVM" pattern.

We need to separate our code to improve maintenance and testing (more on testing in a later post!). One pattern for doing this is MVVM. Similar to Model-View-Controller (MVC), MVVM says,

  1. The Model contains our raw domain information and business behaviors, which may come from various data sources. (See Domain-Driven Design.)
  2. The View Model typically retrieves the Model via a service. The View Model then prepares the information for display.
  3. The View displays whatever's in the View Model.

The separation of concerns here is that

  1. The View doesn't know how the View Model gets its information.
  2. The View Model doesn't know how the Model gets its information.

This allows us to make changes to the View, View Model, and Model mostly independently. We strive for loose coupling.

An important aspect of MVVM is how to bind the View to the View Model. The user interacts with the View, for example clicking a button to increment a counter. This updates the data stored in the View Model (and possibly gets passed to the Model).

However, we can also update the values programmatically directly in the View Model, and we want those values to display in the View. This is two-way binding.

Creating the View Model

Right now, when we click the app's button it runs code in the MainPage.xaml.cs class. If this looks familiar to some of you1, it's because it's the same code-behind approach used in ASP.NET WebForms.

It works, but is too tightly coupled. Let's pull that simple functionality into a View Model.

  1. Add the NuGet package Microsoft.Toolkit.Mvvm.

    This is the same package as CommunityToolkit.Mvvm. Only the namespace differs.

  2. Add a folder named ViewModels, and a file named Counter.cs.

    Some developers append their view models with "VM" or "ViewModel". That's fine, but I don't, 'cause that's what namespaces are for!

  3. Add the following code
    using Microsoft.Toolkit.Mvvm.ComponentModel;
    using Microsoft.Toolkit.Mvvm.Input;
    
    namespace Maui.Progression.ViewModels;
    
    public partial class Counter : ObservableObject
    {
        [ObservableProperty]
        [AlsoNotifyChangeFor(nameof(CountText))]
        int count;
    
        public string CountText
        {
            get
            {
                string text = "Click me";
                if (count > 0)
                {
                    text = $"Clicked {count} " + (count == 1 ? "time" : "times");
                }            
                SemanticScreenReader.Announce(text);
                return text;
            }
        }
    
        [ICommand]
        void IncreaseCounter()
        {
            Count++;
        }
    }
    

The Microsoft.Toolkit.Mvvm package includes code generators to create all of the two-way binding code using the attributes. Here we've

  • Created an observable property named Count (the generator capitalizes the property name for us based on the backing field)
  • Created a read-only property named CountText. This is what we'll display.
  • Told the framework "when you notify of a change in Count, also notify there was a change in CountText."
  • Created a bindable command named IncreaseCounter to do the work.

Notes

  • Must be a partial class
  • Must inherit from ObservableObject
  • The Count property must be incremented, not the backing field.

Let's modify the MainPage view. Change the declarations.

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="Maui.Progression._01.MainPage"
             xmlns:ViewModels="clr-namespace:Maui.Progression._01.ViewModels"
             x:DataType="ViewModels:Counter"
             >

This code allows Intellisense to work. It doesn't perform the binding. That's done in the code-behind as shown later.

  • Adds the ViewModels namespace
  • Declares the view is of type Counter.

Change the button code.

            <Button
                Text="{Binding CountText}"
                SemanticProperties.Hint="Counts the number of times you click"
                Command="{Binding IncreaseCounterCommand}"
                HorizontalOptions="Center" />
  • Binds the button text to our CountText property
  • Binds the button click to the IncreaseCounter command.

    Important The toolkit code generator automatically appends "Command" to methods decorated with ICommand.

Finally, in the MainPage.xaml.cs code-behind, replace with this.

using Maui.Progression._01.ViewModels;

namespace Maui.Progression._01;

public partial class MainPage : ContentPage
{
	public MainPage()
	{
		InitializeComponent();
		BindingContext = new Counter();
	}
}

This is what actually binds the View to the View Model.

Running the code should behave as before.

Tricks

Building for multiple platforms takes awhile. If you're willing to develop mostly for Windows, and check progress on Android/iOS/Mac occasionally, you can comment out the mobile targets.

<PropertyGroup>
    <!--<TargetFrameworks>net6.0-android;net6.0-ios;net6.0-maccatalyst</TargetFrameworks>-->
    <TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net6.0-windows10.0.19041.0</TargetFrameworks>

Troubleshooting

If you get a message "The namespace already contains a definition for 'Counter'," double check you defined Counter as a partial class. You may then have to close Visual Studio, delete the project's bin and obj folders, restart Visual Studio, and build the project.

Sometimes I've seen the MVVM Toolkit generators/code get "stuck," probably due to some files being cached. Even if I comment out most of the class code, the error doesn't go away until I restart Visual Studio.

In some cases, I've cleaned up the VS generated documents. (But I don't know if this matters.)

  1. Open %LocalAppData%\Temp\VSGeneratedDocuments
  2. Delete all the folders

AND, if you're told you can't delete files because they're locked!!

Important
This is probably the most useful troubleshooting step I've found. When you close Visual Studio, it does not close adb.exe, which is used by the Android emulator. I'm betting this will be corrected in the future.

  1. Close Visual Studio
  2. Close any open Android Emulator and the Android Device Manager
  3. Open Task Manager (Ctrl+Shift+Esc)
  4. Open the Details tab
  5. End the adb.exe process

AND IF THAT DOESN'T WORK!!!

Restart and try clearing those files again. And delete the project's obj/bin again. Sheesh!

If you get this error

Argument 1: cannot convert from 'System.ComponentModel.PropertyChangedEventArgs' to 'int'

double check your View Model class is inheriting from ObservableObject, not BindableObject.

Wrap Up

We've actually done quite a bit here to implement the MVVM pattern.

  1. Created a View Model with bindable properties and commands
  2. Updated the View to use the View Model
  3. Bound the two together

Next up: unit testing!

Resources


  1. The ancient ones

How to Hide Markdown Breadcrumbs in VS Code

Here's a quick one. If you're using Visual Studio Code as your Markdown editor and work with larger documents, such as a daily journal, VS Code's default behavior of showing breadcrumbs might be a performance drag.

To turn off header-level breadcrumbs when working with just Markdown files:

  1. View > Command Palette > the JSON User settings > Preferences: Open Settings (JSON)
  2. Add this setting
    "[markdown]": {
        "breadcrumbs.showStrings": false
    },
    

If you want to disable breadcrumbs for all languages, use this setting:

"breadcrumbs.enabled": false

Happy writing!

   Older