Testing and Troubleshooting
Guide to unit testing navigation and solving common issues.
Unit Testing Navigation
Basic Navigation Test
using NSubstitute;
using Xunit;
public class MainPageModelTests
{
[Fact]
public async Task NavigateToDetails_CallsNavigationService()
{
// Arrange
var navigationService = Substitute.For<INavigationService>();
navigationService.GoToAsync(Arg.Any<INavigationInfo>())
.Returns(Task.FromResult(true));
var viewModel = new MainPageModel(navigationService);
// Act
await viewModel.NavigateToDetailsAsync();
// Assert
await navigationService.Received(1).GoToAsync(Arg.Any<INavigationInfo>());
}
}
Testing Specific Navigation
[Fact]
public async Task ViewProduct_NavigatesWithCorrectPageType()
{
// Arrange
var navigationService = Substitute.For<INavigationService>();
var viewModel = new ProductListViewModel(navigationService);
// Act
await viewModel.ViewProductAsync();
// Assert
await navigationService.Received().GoToAsync(
Arg.Is<INavigationInfo>(nav =>
nav.Count == 1 &&
nav[0].Type == typeof(ProductDetailPage)
)
);
}
Testing Navigation with Intents
Use record types for automatic value equality:
// Define intent as record
public record ProductIntent(int ProductId);
[Fact]
public async Task ViewProduct_PassesCorrectIntent()
{
// Arrange
var navigationService = Substitute.For<INavigationService>();
var viewModel = new ProductListViewModel(navigationService);
// Act
await viewModel.ViewProductAsync(42);
// Assert
var expectedNav = Navigation.Relative()
.Push<ProductDetailPageModel>()
.WithIntent(new ProductIntent(42));
await navigationService.Received().GoToAsync(
Arg.Is<INavigationInfo>(n => n.Matches(expectedNav))
);
}
Testing Multiple Navigation Steps
[Fact]
public async Task ComplexNavigation_BuildsCorrectPath()
{
// Arrange
var navigationService = Substitute.For<INavigationService>();
var viewModel = new MyViewModel(navigationService);
// Act
await viewModel.ReplacePageAsync();
// Assert - Pop then Push
await navigationService.Received().GoToAsync(
Arg.Is<INavigationInfo>(nav =>
nav.Count == 2 &&
nav[0] is NavigationPop &&
nav[1].Type == typeof(NewPage)
)
);
}
Testing Absolute Navigation
[Fact]
public async Task NavigateToSettings_UsesAbsoluteNavigation()
{
// Arrange
var navigationService = Substitute.For<INavigationService>();
var viewModel = new MainViewModel(navigationService);
// Act
await viewModel.GoToSettingsAsync();
// Assert
await navigationService.Received().GoToAsync(
Arg.Is<INavigationInfo>(nav =>
nav.IsAbsolute &&
nav[0].Type == typeof(SettingsPage)
)
);
}
Testing Navigation Guards
[Fact]
public async Task CanLeaveAsync_WithUnsavedChanges_ReturnsFalse()
{
// Arrange
var viewModel = new EditPageModel();
viewModel.Data = "modified";
viewModel.MarkAsModified();
// Act
var canLeave = await viewModel.CanLeaveAsync();
// Assert
Assert.False(canLeave);
}
[Fact]
public async Task CanLeaveAsync_WithoutChanges_ReturnsTrue()
{
// Arrange
var viewModel = new EditPageModel();
// Act
var canLeave = await viewModel.CanLeaveAsync();
// Assert
Assert.True(canLeave);
}
Testing Lifecycle Events
[Fact]
public async Task OnEnteringAsync_WithIntent_LoadsProduct()
{
// Arrange
var productService = Substitute.For<IProductService>();
var product = new Product { Id = 42, Name = "Test" };
productService.GetProductAsync(42).Returns(product);
var viewModel = new ProductDetailViewModel(productService);
var intent = new ProductIntent(42);
// Act
await viewModel.OnEnteringAsync(intent);
// Assert
Assert.Equal(product, viewModel.Product);
await productService.Received(1).GetProductAsync(42);
}
[Fact]
public async Task OnAppearingAsync_RefreshesData()
{
// Arrange
var dataService = Substitute.For<IDataService>();
var viewModel = new MyViewModel(dataService);
// Act
await viewModel.OnAppearingAsync();
// Assert
await dataService.Received(1).RefreshAsync();
}
Testing Intent Reception
public class ProductDetailViewModelTests
{
[Fact]
public async Task OnEnteringAsync_WithValidIntent_LoadsCorrectProduct()
{
// Arrange
var repository = Substitute.For<IProductRepository>();
var expectedProduct = new Product { Id = 123, Name = "Widget" };
repository.GetByIdAsync(123).Returns(expectedProduct);
var viewModel = new ProductDetailViewModel(repository);
var intent = new ProductIntent(123);
// Act
await viewModel.OnEnteringAsync(intent);
// Assert
Assert.Equal(expectedProduct, viewModel.Product);
Assert.Equal(123, viewModel.ProductId);
}
[Fact]
public async Task OnEnteringAsync_WithInvalidId_SetsErrorState()
{
// Arrange
var repository = Substitute.For<IProductRepository>();
repository.GetByIdAsync(Arg.Any<int>())
.Returns(Task.FromException<Product>(new NotFoundException()));
var viewModel = new ProductDetailViewModel(repository);
var intent = new ProductIntent(999);
// Act
await viewModel.OnEnteringAsync(intent);
// Assert
Assert.True(viewModel.HasError);
Assert.NotNull(viewModel.ErrorMessage);
}
}
Testing Awaitable Intents
Testing the page that sets the result:
[Fact]
public async Task SelectContact_SetsResultOnIntent()
{
// Arrange
var navigationService = Substitute.For<INavigationService>();
var viewModel = new ContactSelectionViewModel(navigationService);
var intent = new SelectContactIntent();
await viewModel.OnEnteringAsync(intent);
var contact = new Contact { Id = 1, Name = "John" };
// Act
await viewModel.SelectContactCommand.ExecuteAsync(contact);
// Assert - intent should have result set
var result = await intent;
Assert.Equal(contact, result);
// Assert - should pop after setting result
await navigationService.Received().GoToAsync(
Arg.Is<INavigationInfo>(n => n[0] is NavigationPop)
);
}
[Fact]
public async Task Cancel_SetsNullOnIntent()
{
// Arrange
var navigationService = Substitute.For<INavigationService>();
var viewModel = new ContactSelectionViewModel(navigationService);
var intent = new SelectContactIntent();
await viewModel.OnEnteringAsync(intent);
// Act
await viewModel.CancelCommand.ExecuteAsync(null);
// Assert
var result = await intent;
Assert.Null(result);
}
[Fact]
public async Task Error_SetsExceptionOnIntent()
{
// Arrange
var viewModel = new ContactSelectionViewModel();
var intent = new SelectContactIntent();
await viewModel.OnEnteringAsync(intent);
// Act
await viewModel.TriggerErrorAsync();
// Assert
await Assert.ThrowsAsync<ValidationException>(async () => await intent);
}
Testing ResolveIntentAsync usage:
[Fact]
public async Task EditItem_UsesResolveIntentAsync()
{
// Arrange
var navigationService = Substitute.For<INavigationService>();
var item = new Item { Id = 1, Name = "Test" };
// Setup ResolveIntentAsync to return modified item
navigationService
.ResolveIntentAsync<ItemEditorPageModel, Item?>(Arg.Any<EditItemIntent>())
.Returns(Task.FromResult<Item?>(item));
var viewModel = new ItemListViewModel(navigationService);
// Act
await viewModel.EditItemCommand.ExecuteAsync(item);
// Assert
await navigationService.Received().ResolveIntentAsync<ItemEditorPageModel, Item?>(
Arg.Is<EditItemIntent>(i => i.Item.Id == item.Id)
);
}
Integration Tests
For integration tests, use a real NavigationService:
public class NavigationIntegrationTests
{
[Fact]
public async Task FullNavigationFlow_WorksCorrectly()
{
// Arrange
var services = new ServiceCollection();
services.AddScoped<MainPage>();
services.AddScoped<MainPageModel>();
services.AddScoped<DetailPage>();
services.AddScoped<DetailPageModel>();
var config = new NavigationConfiguration();
var serviceProvider = services.BuildServiceProvider();
var navigationService = new NavigationService(config, serviceProvider);
// Act & Assert
var result = await navigationService.GoToAsync(
Navigation.Relative().Push<DetailPageModel>()
);
Assert.True(result);
}
}
Testing Best Practices
- Use
recordfor intents - automatic value equality makes assertions easier - Test navigation, not navigation service - verify your logic, not the framework
- Mock dependencies - focus on the ViewModel behavior
- Test guards separately - they have specific logic worth isolating
- Test lifecycle events - ensure proper initialization and cleanup
- Use helper methods - reduce boilerplate in test setup
Helper method example:
public class TestHelpers
{
public static INavigationService CreateMockNavigationService()
{
var nav = Substitute.For<INavigationService>();
nav.GoToAsync(Arg.Any<INavigationInfo>()).Returns(Task.FromResult(true));
return nav;
}
public static void VerifyNavigation<TPage>(
INavigationService nav,
Times times = default)
{
nav.Received(times ?? 1).GoToAsync(
Arg.Is<INavigationInfo>(n => n[0].Type == typeof(TPage))
);
}
}
Troubleshooting
Navigation Not Working
Symptoms: Navigation call doesn't navigate, no errors thrown
Checklist:
- ✅ Verify
NaluShellinheritance
// ✅ Correct
public partial class AppShell : NaluShell
// ❌ Wrong
public partial class AppShell : Shell
- ✅ Check
nalu:Navigation.PageTypeon allShellContent
<!-- ✅ Correct -->
<ShellContent nalu:Navigation.PageType="pages:MainPage" />
<!-- ❌ Wrong - missing PageType -->
<ShellContent Title="Main" />
- ✅ Verify page registration
// Manual registration
.UseNaluNavigation<App>(nav => nav
.AddPage<MainPageModel, MainPage>()
)
// Or auto-discovery
.UseNaluNavigation<App>(nav => nav.AddPages())
- ✅ Confirm page constructor signature
// ✅ Correct
public partial class MainPage : ContentPage
{
public MainPage(MainPageModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
}
// ❌ Wrong - missing ViewModel parameter
public partial class MainPage : ContentPage
{
public MainPage()
{
InitializeComponent();
}
}
- ✅ Check ViewModel implements
INotifyPropertyChanged
// ✅ Correct
public class MainPageModel : ObservableObject
// ❌ Wrong - plain class
public class MainPageModel
Pages Not Disposing / Memory Leaks
Symptoms: Leak detector alerts, increasing memory usage
Solutions:
- Unsubscribe from events
public class MyPageModel : IAppearingAware, ILeavingAware
{
private readonly IEventAggregator _events;
public ValueTask OnAppearingAsync()
{
_events.Subscribe<MyEvent>(OnMyEvent);
return ValueTask.CompletedTask;
}
public ValueTask OnLeavingAsync()
{
// ✅ Critical: Unsubscribe!
_events.Unsubscribe<MyEvent>(OnMyEvent);
return ValueTask.CompletedTask;
}
}
- Dispose timers and resources
public class MyPageModel : IDisposable
{
private Timer? _timer;
private HttpClient? _httpClient;
public void Dispose()
{
_timer?.Dispose();
_httpClient?.Dispose();
}
}
- Cancel long-running operations
public class MyPageModel : IAppearingAware, ILeavingAware, IDisposable
{
private CancellationTokenSource? _cts;
public async ValueTask OnAppearingAsync()
{
_cts = new CancellationTokenSource();
await LongRunningOperationAsync(_cts.Token);
}
public ValueTask OnLeavingAsync()
{
_cts?.Cancel();
return ValueTask.CompletedTask;
}
public void Dispose()
{
_cts?.Dispose();
}
}
- Avoid static references
// ❌ Bad - prevents GC
public static Page CurrentPage { get; set; }
// ✅ Good - use weak reference if needed
public static WeakReference<Page>? CurrentPageRef { get; set; }
- Enable leak detection
.UseNaluNavigation<App>(nav => nav
.AddPages()
.WithLeakDetectorState(NavigationLeakDetectorState.EnabledWithDebugger)
)
Intent Not Received
Symptoms: Lifecycle method doesn't receive the intent
Solutions:
- Verify interface implementation
// ✅ Correct - implements IEnteringAware<TIntent>
public class DetailPageModel : IEnteringAware<ProductIntent>
{
public ValueTask OnEnteringAsync(ProductIntent intent)
{
// Will receive intent
}
}
// ❌ Wrong - missing generic interface
public class DetailPageModel : IEnteringAware
{
public ValueTask OnEnteringAsync()
{
// Won't receive intent
}
}
- Check intent type matches exactly
// Navigation
.WithIntent(new ProductIntent(42))
// ✅ Matches
public class PageModel : IEnteringAware<ProductIntent>
// ❌ Doesn't match - no inheritance support
public class PageModel : IEnteringAware<BaseIntent>
- Verify intent behavior configuration
// Strict mode (default) - only intent-aware method called
.WithNavigationIntentBehavior(NavigationIntentBehavior.Strict)
// Fallthrough mode - both methods called
.WithNavigationIntentBehavior(NavigationIntentBehavior.Fallthrough)
- Ensure intent is passed
// ✅ Correct - intent included
Navigation.Relative()
.Push<DetailPageModel>()
.WithIntent(new ProductIntent(42))
// ❌ Wrong - no intent
Navigation.Relative()
.Push<DetailPageModel>()
Guards Not Working
Symptoms: Navigation happens even though guard returns false
Solutions:
- Implement
ILeavingGuardcorrectly
// ✅ Correct
public class PageModel : ILeavingGuard
{
public async ValueTask<bool> CanLeaveAsync()
{
return await ConfirmAsync();
}
}
// ❌ Wrong - typo in interface name
public class PageModel : ILeavingGard
- Return false to block navigation
// ✅ Blocks navigation
public async ValueTask<bool> CanLeaveAsync()
{
if (hasUnsavedChanges)
return false; // Blocks!
return true;
}
// ❌ Always allows
public async ValueTask<bool> CanLeaveAsync()
{
await ShowDialogAsync();
return true; // Always allows
}
- Check for
IgnoreGuardsbehavior
// Guards are bypassed with this flag
Navigation.Relative(NavigationBehavior.IgnoreGuards).Pop()
- Ensure guard is on the correct page
// Guard must be on the page being LEFT, not the target page
// Stack: [A, B] -> Pop back to A
// Guard must be on PageModel B, not A
Lifecycle Events Not Firing
Symptoms: OnEnteringAsync, OnAppearingAsync, etc. not called
Solutions:
- Verify interface implementation
// ✅ Correct
public class MyPageModel : IEnteringAware
{
public async ValueTask OnEnteringAsync()
{
// Will be called
}
}
// ❌ Wrong - missing interface
public class MyPageModel
{
public async ValueTask OnEnteringAsync()
{
// Won't be called - no interface
}
}
- Check method signature
// ✅ Correct
public ValueTask OnEnteringAsync()
// ❌ Wrong return type
public Task OnEnteringAsync()
// ❌ Wrong method name
public ValueTask OnEntering()
- Intermediate pages don't "appear"
// Stack: [A, B, C] -> Pop -> Pop -> [A]
// Only A will fire OnAppearingAsync
// B and C will fire OnLeavingAsync but not OnAppearingAsync
OnAppearingAsynconly fires for target page
// When navigating A -> B -> C in one call
// Only C fires OnAppearingAsync
// A and B fire OnDisappearingAsync
await _navigationService.GoToAsync(
Navigation.Relative()
.Push<BPageModel>()
.Push<CPageModel>()
);
Async/Await Issues
Symptoms: Deadlocks, operations not completing
Solutions:
- Don't block on async operations
// ❌ Wrong - can cause deadlocks
public ValueTask OnEnteringAsync()
{
LoadDataAsync().Wait(); // Don't do this!
return ValueTask.CompletedTask;
}
// ✅ Correct
public async ValueTask OnEnteringAsync()
{
await LoadDataAsync();
}
- Use
ConfigureAwaitappropriately
// In UI code, capture context (default)
await LoadDataAsync();
// In library code, don't capture if not needed
await LoadDataAsync().ConfigureAwait(false);
- Return
ValueTask.CompletedTaskfor sync methods
public ValueTask OnEnteringAsync()
{
// Synchronous operation
InitializeData();
return ValueTask.CompletedTask;
}
ViewModel Not Found in DI
Symptoms: Exception about unable to resolve ViewModel type
Solutions:
- Ensure ViewModels are registered
// ✅ Automatic registration
.UseNaluNavigation<App>(nav => nav.AddPages())
// ✅ Manual registration
.UseNaluNavigation<App>(nav => nav
.AddPage<MyPageModel, MyPage>()
)
- Check naming convention
// Default convention: MainPage -> MainPageModel
// If using different convention:
.UseNaluNavigation<App>(nav => nav
.AddPages(pageType =>
pageType.Name.Replace("Page", "ViewModel")
)
)
- Verify ViewModel is in scanned assembly
// Scans the App assembly
.UseNaluNavigation<App>(nav => nav.AddPages())
// If ViewModel is in different assembly, register manually
.UseNaluNavigation<App>(nav => nav
.AddPage<MyPageModel, MyPage>()
)
Android: App Recreates on Foreground
Symptoms: App creates new window when returning from background
Solution: Cache the window instance
public partial class App : Application
{
#if ANDROID
private Window? _window;
#endif
protected override Window CreateWindow(IActivationState? activationState)
{
#if ANDROID
return _window ??= new Window(new AppShell(_navigationService));
#else
return new Window(new AppShell(_navigationService));
#endif
}
}
Debugging Tips
Enable Navigation Event Logging
public partial class AppShell : NaluShell
{
public AppShell(INavigationService navigationService)
: base(navigationService, typeof(MainPage))
{
InitializeComponent();
#if DEBUG
NavigationEvent += (s, e) =>
{
Debug.WriteLine($"[NAV] {e.EventType}: {e.Target}");
};
#endif
}
}
Track Lifecycle Timing
public class MyPageModel : IEnteringAware, IAppearingAware
{
private readonly Stopwatch _stopwatch = new();
public ValueTask OnEnteringAsync()
{
_stopwatch.Restart();
// Your code
Debug.WriteLine($"OnEntering took {_stopwatch.ElapsedMilliseconds}ms");
return ValueTask.CompletedTask;
}
public ValueTask OnAppearingAsync()
{
var total = _stopwatch.ElapsedMilliseconds;
Debug.WriteLine($"Total navigation time: {total}ms");
return ValueTask.CompletedTask;
}
}
Verify Navigation State
private void LogNavigationState()
{
var shell = Shell.Current;
Debug.WriteLine($"Current Item: {shell.CurrentItem.Route}");
Debug.WriteLine($"Current Section: {shell.CurrentItem.CurrentItem.Route}");
Debug.WriteLine($"Current Content: {shell.CurrentItem.CurrentItem.CurrentItem.Route}");
Debug.WriteLine($"Navigation Stack Count: {shell.Navigation.NavigationStack.Count}");
}