Unit Testing Navigation Logic in Avalonia UI: A Developer’s Guide to Rock-Solid Apps

Ever shipped an app only to discover that clicking “Settings” somehow navigated users to the “About” page? Navigation bugs are sneaky little gremlins that can destroy user experience faster than you can say “wrong view.”

Today, we’re building bulletproof navigation logic with comprehensive unit tests. By the end of this guide, you’ll never again wonder if your navigation works – you’ll know it does because your tests will tell you.

What We’re Building Today

We’re creating a complete navigation system with:

  • A testable navigation service
  • ViewModels that handle navigation commands
  • Comprehensive unit tests that catch bugs before users do
  • Real-world scenarios that actually happen in production

Setting Up Your Navigation Architecture

First, let’s create the foundation that makes navigation testing possible. The key is separating navigation logic from UI concerns.

Creating the Navigation Service Interface

Create Services/INavigationService.cs:

using System.ComponentModel;

namespace YourProject.Services;

public interface INavigationService : INotifyPropertyChanged
{
    object? CurrentView { get; }
    bool CanNavigateBack { get; }
    string CurrentViewName { get; }
    
    void NavigateTo<TViewModel>() where TViewModel : class;
    void NavigateTo<TViewModel>(object parameter) where TViewModel : class;
    void NavigateBack();
    void ClearNavigationStack();
    
    event EventHandler<NavigationEventArgs>? Navigated;
    event EventHandler<NavigatingEventArgs>? Navigating;
}

public class NavigationEventArgs : EventArgs
{
    public object? FromView { get; init; }
    public object ToView { get; init; } = null!;
    public object? Parameter { get; init; }
    public string ViewName { get; init; } = string.Empty;
}

public class NavigatingEventArgs : EventArgs
{
    public object? FromView { get; init; }
    public Type ToViewType { get; init; } = null!;
    public object? Parameter { get; init; }
    public bool Cancel { get; set; }
}

Implementing the Navigation Service

Create Services/NavigationService.cs:

using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.Generic;

namespace YourProject.Services;

public partial class NavigationService : ObservableObject, INavigationService
{
    private readonly IServiceProvider _serviceProvider;
    private readonly Stack<object> _navigationStack = new();
    
    [ObservableProperty]
    private object? currentView;
    
    [ObservableProperty]
    private string currentViewName = string.Empty;
    
    public bool CanNavigateBack => _navigationStack.Count > 1;
    
    public event EventHandler<NavigationEventArgs>? Navigated;
    public event EventHandler<NavigatingEventArgs>? Navigating;
    
    public NavigationService(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }
    
    public void NavigateTo<TViewModel>() where TViewModel : class
    {
        NavigateTo<TViewModel>(null);
    }
    
    public void NavigateTo<TViewModel>(object? parameter) where TViewModel : class
    {
        var navigatingArgs = new NavigatingEventArgs
        {
            FromView = CurrentView,
            ToViewType = typeof(TViewModel),
            Parameter = parameter
        };
        
        Navigating?.Invoke(this, navigatingArgs);
        
        if (navigatingArgs.Cancel)
            return;
            
        var fromView = CurrentView;
        var toView = _serviceProvider.GetRequiredService<TViewModel>();
        
        // Handle parameter passing if the ViewModel supports it
        if (parameter != null && toView is INavigationAware navigationAware)
        {
            navigationAware.OnNavigatedTo(parameter);
        }
        
        CurrentView = toView;
        CurrentViewName = typeof(TViewModel).Name;
        
        _navigationStack.Push(toView);
        
        var navigatedArgs = new NavigationEventArgs
        {
            FromView = fromView,
            ToView = toView,
            Parameter = parameter,
            ViewName = CurrentViewName
        };
        
        Navigated?.Invoke(this, navigatedArgs);
    }
    
    public void NavigateBack()
    {
        if (!CanNavigateBack)
            return;
            
        _navigationStack.Pop(); // Remove current view
        
        var previousView = _navigationStack.Peek();
        CurrentView = previousView;
        CurrentViewName = previousView.GetType().Name;
        
        var navigatedArgs = new NavigationEventArgs
        {
            FromView = null, // We don't track the previous view in back navigation
            ToView = previousView,
            ViewName = CurrentViewName
        };
        
        Navigated?.Invoke(this, navigatedArgs);
    }
    
    public void ClearNavigationStack()
    {
        var currentView = CurrentView;
        _navigationStack.Clear();
        
        if (currentView != null)
        {
            _navigationStack.Push(currentView);
        }
    }
}

public interface INavigationAware
{
    void OnNavigatedTo(object parameter);
    void OnNavigatedFrom();
}

Creating ViewModels with Navigation

Let’s create some ViewModels that demonstrate different navigation scenarios:

// ViewModels/HomeViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using YourProject.Services;

namespace YourProject.ViewModels;

public partial class HomeViewModel : ObservableObject
{
    private readonly INavigationService _navigationService;
    
    [ObservableProperty]
    private string welcomeMessage = "Welcome to the Home Page!";
    
    public HomeViewModel(INavigationService navigationService)
    {
        _navigationService = navigationService;
    }
    
    [RelayCommand]
    private void NavigateToSettings()
    {
        _navigationService.NavigateTo<SettingsViewModel>();
    }
    
    [RelayCommand]
    private void NavigateToProfile()
    {
        _navigationService.NavigateTo<ProfileViewModel>();
    }
    
    [RelayCommand]
    private void NavigateToUserProfile(string userId)
    {
        _navigationService.NavigateTo<UserProfileViewModel>(userId);
    }
}

// ViewModels/SettingsViewModel.cs
public partial class SettingsViewModel : ObservableObject
{
    private readonly INavigationService _navigationService;
    
    [ObservableProperty]
    private bool isDarkMode = false;
    
    [ObservableProperty]
    private string appVersion = "1.0.0";
    
    public SettingsViewModel(INavigationService navigationService)
    {
        _navigationService = navigationService;
    }
    
    [RelayCommand]
    private void NavigateBack()
    {
        _navigationService.NavigateBack();
    }
    
    [RelayCommand]
    private void NavigateToAbout()
    {
        _navigationService.NavigateTo<AboutViewModel>();
    }
}

// ViewModels/UserProfileViewModel.cs
public partial class UserProfileViewModel : ObservableObject, INavigationAware
{
    private readonly INavigationService _navigationService;
    
    [ObservableProperty]
    private string userId = string.Empty;
    
    [ObservableProperty]
    private string userName = "Loading...";
    
    [ObservableProperty]
    private bool isLoading = true;
    
    public UserProfileViewModel(INavigationService navigationService)
    {
        _navigationService = navigationService;
    }
    
    public void OnNavigatedTo(object parameter)
    {
        if (parameter is string id)
        {
            UserId = id;
            LoadUserData();
        }
    }
    
    public void OnNavigatedFrom()
    {
        // Cleanup if needed
    }
    
    private async void LoadUserData()
    {
        IsLoading = true;
        
        // Simulate API call
        await Task.Delay(1000);
        
        UserName = $"User {UserId}";
        IsLoading = false;
    }
    
    [RelayCommand]
    private void NavigateBack()
    {
        _navigationService.NavigateBack();
    }
}

Writing Comprehensive Navigation Tests

Now comes the exciting part – writing tests that ensure your navigation works flawlessly. We’ll test every scenario users might encounter.

Setting Up Your Test Project

First, ensure your test project has the right packages:

# Add the testing packages
dotnet add YourProject.Tests package xunit
dotnet add YourProject.Tests package xunit.runner.visualstudio
dotnet add YourProject.Tests package Microsoft.NET.Test.Sdk
dotnet add YourProject.Tests package Moq
dotnet add YourProject.Tests package FluentAssertions
dotnet add YourProject.Tests package Microsoft.Extensions.DependencyInjection

Testing the Navigation Service

Create Tests/Services/NavigationServiceTests.cs:

using Xunit;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using YourProject.Services;
using YourProject.ViewModels;
using Moq;

namespace YourProject.Tests.Services;

public class NavigationServiceTests : IDisposable
{
    private readonly ServiceProvider _serviceProvider;
    private readonly NavigationService _navigationService;
    
    public NavigationServiceTests()
    {
        var services = new ServiceCollection();
        services.AddTransient<HomeViewModel>();
        services.AddTransient<SettingsViewModel>();
        services.AddTransient<UserProfileViewModel>();
        services.AddSingleton<INavigationService, NavigationService>();
        
        _serviceProvider = services.BuildServiceProvider();
        _navigationService = (NavigationService)_serviceProvider.GetRequiredService<INavigationService>();
    }
    
    [Fact]
    public void NavigateTo_FirstNavigation_ShouldSetCurrentView()
    {
        // Act
        _navigationService.NavigateTo<HomeViewModel>();
        
        // Assert
        _navigationService.CurrentView.Should().BeOfType<HomeViewModel>();
        _navigationService.CurrentViewName.Should().Be("HomeViewModel");
        _navigationService.CanNavigateBack.Should().BeFalse();
    }
    
    [Fact]
    public void NavigateTo_MultipleNavigations_ShouldUpdateCurrentView()
    {
        // Arrange
        _navigationService.NavigateTo<HomeViewModel>();
        
        // Act
        _navigationService.NavigateTo<SettingsViewModel>();
        
        // Assert
        _navigationService.CurrentView.Should().BeOfType<SettingsViewModel>();
        _navigationService.CurrentViewName.Should().Be("SettingsViewModel");
        _navigationService.CanNavigateBack.Should().BeTrue();
    }
    
    [Fact]
    public void NavigateBack_WithNavigationHistory_ShouldReturnToPreviousView()
    {
        // Arrange
        _navigationService.NavigateTo<HomeViewModel>();
        _navigationService.NavigateTo<SettingsViewModel>();
        
        // Act
        _navigationService.NavigateBack();
        
        // Assert
        _navigationService.CurrentView.Should().BeOfType<HomeViewModel>();
        _navigationService.CanNavigateBack.Should().BeFalse();
    }
    
    [Fact]
    public void NavigateBack_WithoutNavigationHistory_ShouldNotChangeView()
    {
        // Arrange
        _navigationService.NavigateTo<HomeViewModel>();
        var initialView = _navigationService.CurrentView;
        
        // Act
        _navigationService.NavigateBack();
        
        // Assert
        _navigationService.CurrentView.Should().Be(initialView);
        _navigationService.CanNavigateBack.Should().BeFalse();
    }
    
    [Fact]
    public void NavigateTo_WithParameter_ShouldPassParameterToNavigationAwareViewModel()
    {
        // Arrange
        const string userId = "123";
        
        // Act
        _navigationService.NavigateTo<UserProfileViewModel>(userId);
        
        // Assert
        var viewModel = _navigationService.CurrentView.Should().BeOfType<UserProfileViewModel>().Subject;
        viewModel.UserId.Should().Be(userId);
    }
    
    [Fact]
    public void ClearNavigationStack_ShouldResetCanNavigateBack()
    {
        // Arrange
        _navigationService.NavigateTo<HomeViewModel>();
        _navigationService.NavigateTo<SettingsViewModel>();
        _navigationService.CanNavigateBack.Should().BeTrue();
        
        // Act
        _navigationService.ClearNavigationStack();
        
        // Assert
        _navigationService.CanNavigateBack.Should().BeFalse();
        _navigationService.CurrentView.Should().BeOfType<SettingsViewModel>(); // Current view unchanged
    }
    
    public void Dispose()
    {
        _serviceProvider.Dispose();
    }
}

Testing Navigation Events

[Fact]
public void NavigateTo_ShouldRaiseNavigatingAndNavigatedEvents()
{
    // Arrange
    NavigatingEventArgs? navigatingArgs = null;
    NavigationEventArgs? navigatedArgs = null;
    
    _navigationService.Navigating += (_, e) => navigatingArgs = e;
    _navigationService.Navigated += (_, e) => navigatedArgs = e;
    
    // Act
    _navigationService.NavigateTo<HomeViewModel>();
    
    // Assert
    navigatingArgs.Should().NotBeNull();
    navigatingArgs!.ToViewType.Should().Be<HomeViewModel>();
    
    navigatedArgs.Should().NotBeNull();
    navigatedArgs!.ToView.Should().BeOfType<HomeViewModel>();
    navigatedArgs.ViewName.Should().Be("HomeViewModel");
}

[Fact]
public void Navigating_WhenCanceled_ShouldNotNavigate()
{
    // Arrange
    _navigationService.NavigateTo<HomeViewModel>();
    _navigationService.Navigating += (_, e) => e.Cancel = true;
    
    var initialView = _navigationService.CurrentView;
    
    // Act
    _navigationService.NavigateTo<SettingsViewModel>();
    
    // Assert
    _navigationService.CurrentView.Should().Be(initialView);
}

Testing ViewModels with Navigation

Now let’s test how our ViewModels interact with the navigation service:

Testing Navigation Commands

Create Tests/ViewModels/HomeViewModelTests.cs:

using Xunit;
using FluentAssertions;
using Moq;
using YourProject.ViewModels;
using YourProject.Services;

namespace YourProject.Tests.ViewModels;

public class HomeViewModelTests
{
    private readonly Mock<INavigationService> _mockNavigationService;
    private readonly HomeViewModel _homeViewModel;
    
    public HomeViewModelTests()
    {
        _mockNavigationService = new Mock<INavigationService>();
        _homeViewModel = new HomeViewModel(_mockNavigationService.Object);
    }
    
    [Fact]
    public void NavigateToSettingsCommand_WhenExecuted_ShouldCallNavigationService()
    {
        // Act
        _homeViewModel.NavigateToSettingsCommand.Execute(null);
        
        // Assert
        _mockNavigationService.Verify(x => x.NavigateTo<SettingsViewModel>(), Times.Once);
    }
    
    [Fact]
    public void NavigateToProfileCommand_WhenExecuted_ShouldCallNavigationService()
    {
        // Act
        _homeViewModel.NavigateToProfileCommand.Execute(null);
        
        // Assert
        _mockNavigationService.Verify(x => x.NavigateTo<ProfileViewModel>(), Times.Once);
    }
    
    [Fact]
    public void NavigateToUserProfileCommand_WithUserId_ShouldPassParameter()
    {
        // Arrange
        const string userId = "test123";
        
        // Act
        _homeViewModel.NavigateToUserProfileCommand.Execute(userId);
        
        // Assert
        _mockNavigationService.Verify(x => 
            x.NavigateTo<UserProfileViewModel>(userId), Times.Once);
    }
}

Testing Complex Navigation Scenarios

public class NavigationScenariosTests
{
    private readonly Mock<INavigationService> _mockNavigationService;
    
    public NavigationScenariosTests()
    {
        _mockNavigationService = new Mock<INavigationService>();
    }
    
    [Fact]
    public void SettingsViewModel_NavigateBack_WhenCanNavigateBack_ShouldCallService()
    {
        // Arrange
        _mockNavigationService.Setup(x => x.CanNavigateBack).Returns(true);
        var settingsViewModel = new SettingsViewModel(_mockNavigationService.Object);
        
        // Act
        settingsViewModel.NavigateBackCommand.Execute(null);
        
        // Assert
        _mockNavigationService.Verify(x => x.NavigateBack(), Times.Once);
    }
    
    [Fact]
    public void NestedNavigation_ShouldMaintainCorrectStack()
    {
        // This test verifies complex navigation flows
        var services = new ServiceCollection();
        services.AddTransient<HomeViewModel>();
        services.AddTransient<SettingsViewModel>();
        services.AddTransient<AboutViewModel>();
        services.AddSingleton<INavigationService, NavigationService>();
        
        using var serviceProvider = services.BuildServiceProvider();
        var navigationService = serviceProvider.GetRequiredService<INavigationService>();
        
        // Simulate: Home -> Settings -> About -> Back -> Back
        navigationService.NavigateTo<HomeViewModel>();
        navigationService.NavigateTo<SettingsViewModel>();
        navigationService.NavigateTo<AboutViewModel>();
        
        navigationService.CurrentView.Should().BeOfType<AboutViewModel>();
        navigationService.CanNavigateBack.Should().BeTrue();
        
        navigationService.NavigateBack(); // Back to Settings
        navigationService.CurrentView.Should().BeOfType<SettingsViewModel>();
        
        navigationService.NavigateBack(); // Back to Home
        navigationService.CurrentView.Should().BeOfType<HomeViewModel>();
        navigationService.CanNavigateBack.Should().BeFalse();
    }
}

Testing Navigation with Parameters

Parameter passing is where many navigation systems break. Let’s test it thoroughly:

public class ParameterNavigationTests
{
    [Fact]
    public void UserProfileViewModel_OnNavigatedTo_ShouldSetUserId()
    {
        // Arrange
        var mockNavigationService = new Mock<INavigationService>();
        var userProfileViewModel = new UserProfileViewModel(mockNavigationService.Object);
        const string expectedUserId = "user123";
        
        // Act
        userProfileViewModel.OnNavigatedTo(expectedUserId);
        
        // Assert
        userProfileViewModel.UserId.Should().Be(expectedUserId);
    }
    
    [Fact]
    public void UserProfileViewModel_OnNavigatedTo_WithNullParameter_ShouldHandleGracefully()
    {
        // Arrange
        var mockNavigationService = new Mock<INavigationService>();
        var userProfileViewModel = new UserProfileViewModel(mockNavigationService.Object);
        
        // Act & Assert (should not throw)
        var exception = Record.Exception(() => userProfileViewModel.OnNavigatedTo(null!));
        exception.Should().BeNull();
    }
    
    [Theory]
    [InlineData("")]
    [InlineData("123")]
    [InlineData("user-abc-456")]
    public void NavigationWithDifferentUserIds_ShouldWork(string userId)
    {
        // Arrange
        var services = new ServiceCollection();
        services.AddTransient<UserProfileViewModel>();
        services.AddSingleton<INavigationService, NavigationService>();
        
        using var serviceProvider = services.BuildServiceProvider();
        var navigationService = serviceProvider.GetRequiredService<INavigationService>();
        
        // Act
        navigationService.NavigateTo<UserProfileViewModel>(userId);
        
        // Assert
        var viewModel = navigationService.CurrentView.Should().BeOfType<UserProfileViewModel>().Subject;
        viewModel.UserId.Should().Be(userId);
    }
}

Testing Navigation Guards and Validation

Sometimes you need to prevent navigation under certain conditions:

public class NavigationGuardTests
{
    [Fact]
    public void NavigationService_WithCancelledNavigating_ShouldNotNavigate()
    {
        // Arrange
        var services = new ServiceCollection();
        services.AddTransient<HomeViewModel>();
        services.AddTransient<SettingsViewModel>();
        services.AddSingleton<INavigationService, NavigationService>();
        
        using var serviceProvider = services.BuildServiceProvider();
        var navigationService = serviceProvider.GetRequiredService<INavigationService>();
        
        navigationService.NavigateTo<HomeViewModel>();
        var initialView = navigationService.CurrentView;
        
        // Set up navigation guard
        navigationService.Navigating += (_, e) =>
        {
            if (e.ToViewType == typeof(SettingsViewModel))
                e.Cancel = true;
        };
        
        // Act
        navigationService.NavigateTo<SettingsViewModel>();
        
        // Assert
        navigationService.CurrentView.Should().Be(initialView);
        navigationService.CurrentView.Should().BeOfType<HomeViewModel>();
    }
}

Running Your Navigation Tests

On EndeavourOS, testing your navigation is straightforward:

# Run all navigation tests
dotnet test --filter "NavigationService"

# Run with detailed output to see exactly what's being tested
dotnet test --logger "console;verbosity=detailed"

# Run tests and watch for changes during development
dotnet watch test --filter "Navigation"

# Generate a test coverage report
dotnet test --collect:"XPlat Code Coverage"

Advanced Testing Patterns

Testing Async Navigation

If your navigation involves async operations:

[Fact]
public async Task NavigateToAsync_WithAsyncOperation_ShouldComplete()
{
    // Arrange
    var mockNavigationService = new Mock<INavigationService>();
    var homeViewModel = new HomeViewModel(mockNavigationService.Object);
    
    // Act
    await homeViewModel.NavigateToUserProfileCommand.ExecuteAsync("123");
    
    // Assert
    mockNavigationService.Verify(x => 
        x.NavigateTo<UserProfileViewModel>("123"), Times.Once);
}

Testing Navigation State Persistence

[Fact]
public void NavigationService_ShouldMaintainStateAcrossNavigations()
{
    // Test that your navigation service correctly maintains state
    // This is crucial for apps that need to survive configuration changes
}

Common Navigation Testing Pitfalls

1. Not Testing Edge Cases

Always test what happens when:

  • Users mash the back button rapidly
  • Navigation is called with null parameters
  • The navigation stack becomes empty

2. Forgetting to Test Cleanup

Make sure your ViewModels properly clean up when navigating away:

[Fact]
public void ViewModel_OnNavigatedFrom_ShouldCleanupResources()
{
    // Test that subscriptions are removed, timers stopped, etc.
}

3. Not Mocking External Dependencies

Always mock services that your ViewModels depend on:

// Good: Isolated test
var mockDataService = new Mock<IDataService>();
var viewModel = new MyViewModel(mockNavigationService.Object, mockDataService.Object);

// Bad: Real dependencies that might fail
var viewModel = new MyViewModel(new NavigationService(), new RealDataService());

Wrapping Up: Navigation You Can Trust

You’ve just built a navigation system that’s bulletproof. Your tests cover:

  • ✅ Basic navigation between views
  • ✅ Back navigation and stack management
  • ✅ Parameter passing and validation
  • ✅ Navigation events and guards
  • ✅ Edge cases and error scenarios
  • ✅ Complex navigation flows

The best part? Every time you make changes to your navigation logic, your tests will immediately tell you if something breaks. No more wondering if that “quick fix” accidentally broke the user flow three screens deep.

Happy testing!

Mohammed Chami
Mohammed Chami
Articles: 45

Leave a Reply

Your email address will not be published. Required fields are marked *