Mohammed Chami
.NET Developer | Content Creator
Mohammed Chami
.NET Developer | Content Creator

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.
We’re creating a complete navigation system with:
First, let’s create the foundation that makes navigation testing possible. The key is separating navigation logic from UI concerns.
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; }
}
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();
}
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();
}
}
Now comes the exciting part – writing tests that ensure your navigation works flawlessly. We’ll test every scenario users might encounter.
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
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();
}
}
[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);
}
Now let’s test how our ViewModels interact with the navigation service:
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);
}
}
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();
}
}
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);
}
}
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>();
}
}
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"
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);
}
[Fact]
public void NavigationService_ShouldMaintainStateAcrossNavigations()
{
// Test that your navigation service correctly maintains state
// This is crucial for apps that need to survive configuration changes
}
Always test what happens when:
Make sure your ViewModels properly clean up when navigating away:
[Fact]
public void ViewModel_OnNavigatedFrom_ShouldCleanupResources()
{
// Test that subscriptions are removed, timers stopped, etc.
}
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());
You’ve just built a navigation system that’s bulletproof. Your tests cover:
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!